Compare commits

..
Author SHA1 Message Date
Jörg Breitbart 2f49f321c1 remove layout stuff 2026-05-18 21:15:29 +02:00
Jörg Breitbart c0007301c1 multiple changes:
- layout detector
- browser helper to create layout maps
- DE, ES, GR, RU, US maps
2026-05-16 18:08:10 +02:00
Jörg Breitbart 0d8996395c fix AZERTY, layout map generator 2026-05-14 21:05:39 +02:00
Jörg Breitbart e24b043ab2 add DE_QWERTZ, FR_AZERTY and US_QWERTY layouts 2026-05-14 19:27:50 +02:00
Jörg Breitbart 6f15b0f916 modifier capslock, numlock & shift 2026-05-14 00:43:45 +02:00
Jörg Breitbart 33eb1dd45e add modifier keys 2026-05-13 20:16:33 +02:00
Jörg Breitbart 0ca8107055 german qwertz mode 1 no modifier mapping 2026-05-13 17:30:15 +02:00
Jörg Breitbart d844070855 draft of keytester 2026-05-12 15:35:54 +02:00
41 changed files with 4489 additions and 2261 deletions
-4
View File
@@ -3,7 +3,6 @@ node_modules/
.lock-wscript
lib/
out/
out-benchmark/
out-demo/
out-test/
out-esbuild/
@@ -23,9 +22,6 @@ build/
yarn.lock
test-results/
# Tracing/debug files
.cursor/nal-trace/
# Keep bundled code out of Git
dist/
demo/dist/
-1
View File
@@ -253,7 +253,6 @@ Xterm.js is used in many world-class applications to provide great terminal expe
- [**SSH Connection Manager**](https://github.com/Amtrend/ssh-manager): Modern web-based SSH terminal and SFTP manager with AES-256 encryption, PWA support, and session management.
- [**WooTTY**](https://github.com/icoretech/wootty): Flawless browser terminal for real operators.
- [**Orca**](https://github.com/stablyai/orca): Agentic development environment for 100x builders.
- [**4bit**](https://ciembor.github.io/4bit): Terminal Color Scheme Designer.
- [And much more...](https://github.com/xtermjs/xterm.js/network/dependents?package_id=UGFja2FnZS0xNjYzMjc4OQ%3D%3D)
Do you use xterm.js in your application as well? Please [open a Pull Request](https://github.com/sourcelair/xterm.js/pulls) to include it here. We would love to have it on our list. Please add any new contributions to the end of the list.
@@ -533,8 +533,8 @@ export class KittyGraphicsHandler implements IApcHandler, IResetHandler, IDispos
private _sendResponse(id: number, message: string, quiet: number, placementId?: number): void {
const isOk = message === 'OK';
if (isOk && quiet >= 1) return;
if (!isOk && quiet >= 2) return;
if (isOk && quiet === 1) return;
if (!isOk && quiet === 2) return;
const pPart = placementId ? `,p=${placementId}` : '';
const response = `\x1b_Gi=${id}${pPart};${message}\x1b\\`;
@@ -617,18 +617,6 @@ test.describe('Kitty Graphics Protocol', () => {
strictEqual(await ctx.page.evaluate('window.kittyGotResponse'), false);
});
test('suppresses OK response when q=2', async () => {
await ctx.page.evaluate(() => {
(window as any).kittyGotResponse = false;
(window as any).term.onData(() => { (window as any).kittyGotResponse = true; });
});
await ctx.proxy.write(`\x1b_Gi=81,a=q,q=2,f=100;${KITTY_BLACK_1X1_BASE64}\x1b\\`);
await timeout(100);
strictEqual(await ctx.page.evaluate('window.kittyGotResponse'), false);
});
test('suppresses error response when q=2', async () => {
await ctx.page.evaluate(() => {
(window as any).kittyGotResponse = false;
@@ -809,30 +797,6 @@ test.describe('Kitty Graphics Protocol', () => {
strictEqual(await ctx.page.evaluate('window.kittyGotResponse'), false);
});
test('a=t OK suppressed by q=2', async () => {
await ctx.page.evaluate(() => {
(window as any).kittyGotResponse = false;
(window as any).term.onData(() => { (window as any).kittyGotResponse = true; });
});
await ctx.proxy.write(`\x1b_Gi=181,a=t,q=2,f=100;${KITTY_BLACK_1X1_BASE64}\x1b\\`);
await timeout(100);
strictEqual(await ctx.page.evaluate('window.kittyGotResponse'), false);
});
test('a=T OK suppressed by q=2', async () => {
await ctx.page.evaluate(() => {
(window as any).kittyGotResponse = false;
(window as any).term.onData(() => { (window as any).kittyGotResponse = true; });
});
await ctx.proxy.write(`\x1b_Gi=191,a=T,q=2,f=100;${KITTY_BLACK_1X1_BASE64}\x1b\\`);
await timeout(100);
strictEqual(await ctx.page.evaluate('window.kittyGotResponse'), false);
});
});
test.describe('Transmission medium rejection', () => {
@@ -1182,22 +1146,6 @@ test.describe('Kitty Graphics Protocol', () => {
strictEqual(await getImageStorageLength(), 1);
});
test('OK response suppressed by q=2', async () => {
await ctx.proxy.write(`\x1b_Ga=t,f=100,i=221;${KITTY_BLACK_1X1_BASE64}\x1b\\`);
await timeout(100);
await ctx.page.evaluate(() => {
(window as any).kittyGotResponse = false;
(window as any).term.onData(() => { (window as any).kittyGotResponse = true; });
});
await ctx.proxy.write(`\x1b_Ga=p,i=221,q=2\x1b\\`);
await timeout(100);
strictEqual(await ctx.page.evaluate('window.kittyGotResponse'), false);
strictEqual(await getImageStorageLength(), 1);
});
test('ENOENT error suppressed by q=2', async () => {
await ctx.page.evaluate(() => {
(window as any).kittyGotResponse = false;
+2 -2
View File
@@ -20,7 +20,7 @@
"package": "webpack",
"pretest": "npm run build",
"test": "nyc mocha out/**/*.test.js",
"prepublishOnly": "npm run package"
"prepublish": "npm run package"
},
"keywords": [
"font",
@@ -37,7 +37,7 @@
"devDependencies": {
"@types/lru-cache": "^5.1.0",
"@types/opentype.js": "^0.7.0",
"axios": "^1.15.2",
"axios": "^1.15.0",
"font-finder": "^1.1.0",
"mkdirp": "0.5.5",
"yauzl": "^3.2.1"
+1 -2
View File
@@ -1,3 +1,2 @@
lib
node_modules
out-benchmark
node_modules
-9
View File
@@ -20,13 +20,4 @@ terminal.loadAddon(searchAddon);
searchAddon.findNext('foo');
```
### Search options
- `decorations` (`ISearchDecorationOptions`): Enables match highlighting and result tracking; pass a decoration options object (colors may be omitted).
- `highlightLimit` (constructor option): Caps how many matches are tracked/highlighted (default `1000`).
- `incremental`: Expands the current selection when the term still matches.
- `wholeWord`, `regex`, `caseSensitive`: Control match semantics.
`onDidChangeResults` fires after `findNext`/`findPrevious` when `decorations` is set. `clearDecorations()` clears highlights and tracked results but does not emit this event.
See the full [API](https://github.com/xtermjs/xterm.js/blob/master/addons/addon-search/typings/addon-search.d.ts) for more advanced usage.
@@ -1,284 +0,0 @@
/**
* Copyright (c) 2026 The xterm.js authors. All rights reserved.
* @license MIT
*/
import { perfContext, before, RuntimeCase } from 'xterm-benchmark';
import { readFileSync } from 'fs';
import { resolve } from 'path';
import { Terminal } from 'browser/public/Terminal';
import { SearchAddon } from 'SearchAddon';
import type { ISearchOptions } from '@xterm/addon-search';
const enum Constants {
COLS = 120,
ROWS = 40,
SCROLLBACK = 8000,
NAVIGATION_ITERATIONS = 250,
COLD_CACHE_ITERATIONS = 75,
INCREMENTAL_ITERATIONS = 75,
DECORATION_REFRESH_ITERATIONS = 25
}
const DECORATION_OPTIONS: ISearchOptions = {
decorations: {
activeMatchBackground: '#0000ff',
activeMatchColorOverviewRuler: '#ff0000',
matchBackground: '#ffff00',
matchBorder: '#000000',
matchOverviewRuler: '#00ffff'
}
};
class TestTerminal extends Terminal {
public writeSync(data: string): void {
(this as any)._core.writeSync(data);
}
public override select(_column: number, _row: number, _length: number): void {
// SearchAddon always selects matches; skip selection internals in headless benchmark runs.
}
public override clearSelection(): void {
// SearchAddon clears the previous selection before selecting another match.
}
public override scrollLines(_amount: number): void {
// No viewport scrolling is needed for API runtime measurement.
}
}
function buildRealWorldBuffer(): string {
const fixture = readFileSync(resolve(__dirname, '../../fixtures/issue-2444'), 'utf8')
.replace(/\r?\n/g, '\r\n');
const lines: string[] = [fixture];
for (let i = 0; i < 2500; i++) {
const minute = (i % 60).toString().padStart(2, '0');
const second = ((i * 7) % 60).toString().padStart(2, '0');
lines.push(`2026-05-25T16:${minute}:${second}Z INFO [api-${i % 8}] requestId=req-${i.toString(16).padStart(6, '0')} path=/v1/projects/${i % 120}/search?q=opencv status=200 durationMs=${10 + (i % 90)}`);
if (i % 5 === 0) {
lines.push(`2026-05-25T16:${minute}:${second}Z WARN [worker-${i % 4}] slow query detected query="SELECT * FROM runs WHERE status='pending'" elapsedMs=${500 + (i % 1200)}`);
}
if (i % 11 === 0) {
lines.push(`2026-05-25T16:${minute}:${second}Z ERROR [worker-${i % 4}] command failed: package.json parse error at column ${10 + (i % 70)}; retrying in ${2 + (i % 7)}s`);
}
if (i % 17 === 0) {
lines.push(`https://example.internal/run/${i}/details?source=terminal-search&state=error`);
}
}
return lines.join('\r\n') + '\r\n';
}
perfContext('SearchAddon API on real-world terminal content', () => {
const bufferContent = buildRealWorldBuffer();
perfContext('findNext/plain navigation', () => {
let terminal: TestTerminal;
let search: SearchAddon;
before(() => {
terminal = new TestTerminal({ cols: Constants.COLS, rows: Constants.ROWS, scrollback: Constants.SCROLLBACK });
search = new SearchAddon();
search.activate(terminal);
terminal.writeSync(bufferContent);
});
new RuntimeCase('', () => {
let foundCount = 0;
for (let i = 0; i < Constants.NAVIGATION_ITERATIONS; i++) {
if (search.findNext('opencv')) {
foundCount++;
}
}
if (foundCount !== Constants.NAVIGATION_ITERATIONS) {
throw new Error(`Expected ${Constants.NAVIGATION_ITERATIONS} matches, got ${foundCount}`);
}
return { payloadSize: Constants.NAVIGATION_ITERATIONS, foundCount };
}, { fork: false }).showAverageRuntime();
});
perfContext('findNext/no match full scan', () => {
let terminal: TestTerminal;
let search: SearchAddon;
before(() => {
terminal = new TestTerminal({ cols: Constants.COLS, rows: Constants.ROWS, scrollback: Constants.SCROLLBACK });
search = new SearchAddon();
search.activate(terminal);
terminal.writeSync(bufferContent);
});
new RuntimeCase('', () => {
let foundCount = 0;
for (let i = 0; i < Constants.NAVIGATION_ITERATIONS; i++) {
if (search.findNext('zzzznotfoundzzzz')) {
foundCount++;
}
}
if (foundCount !== 0) {
throw new Error(`Expected 0 matches, got ${foundCount}`);
}
return { payloadSize: Constants.NAVIGATION_ITERATIONS, foundCount };
}, { fork: false }).showAverageRuntime();
});
perfContext('findNext/case insensitive navigation', () => {
let terminal: TestTerminal;
let search: SearchAddon;
before(() => {
terminal = new TestTerminal({ cols: Constants.COLS, rows: Constants.ROWS, scrollback: Constants.SCROLLBACK });
search = new SearchAddon();
search.activate(terminal);
terminal.writeSync(bufferContent);
});
new RuntimeCase('', () => {
let foundCount = 0;
for (let i = 0; i < Constants.NAVIGATION_ITERATIONS; i++) {
if (search.findNext('OPENCV', { caseSensitive: false })) {
foundCount++;
}
}
if (foundCount !== Constants.NAVIGATION_ITERATIONS) {
throw new Error(`Expected ${Constants.NAVIGATION_ITERATIONS} matches, got ${foundCount}`);
}
return { payloadSize: Constants.NAVIGATION_ITERATIONS, foundCount };
}, { fork: false }).showAverageRuntime();
});
perfContext('findNext/regex navigation', () => {
let terminal: TestTerminal;
let search: SearchAddon;
before(() => {
terminal = new TestTerminal({ cols: Constants.COLS, rows: Constants.ROWS, scrollback: Constants.SCROLLBACK });
search = new SearchAddon();
search.activate(terminal);
terminal.writeSync(bufferContent);
});
new RuntimeCase('', () => {
let foundCount = 0;
for (let i = 0; i < Constants.NAVIGATION_ITERATIONS; i++) {
if (search.findNext('https://example\\.internal/run/\\d+/details\\?source=terminal-search', { regex: true })) {
foundCount++;
}
}
if (foundCount !== Constants.NAVIGATION_ITERATIONS) {
throw new Error(`Expected ${Constants.NAVIGATION_ITERATIONS} matches, got ${foundCount}`);
}
return { payloadSize: Constants.NAVIGATION_ITERATIONS, foundCount };
}, { fork: false }).showAverageRuntime();
});
perfContext('findPrevious/incremental typing flow', () => {
let terminal: TestTerminal;
let search: SearchAddon;
const terms = ['e', 'er', 'err', 'erro', 'error'];
before(() => {
terminal = new TestTerminal({ cols: Constants.COLS, rows: Constants.ROWS, scrollback: Constants.SCROLLBACK });
search = new SearchAddon();
search.activate(terminal);
terminal.writeSync(bufferContent);
});
new RuntimeCase('', () => {
let foundCount = 0;
for (let i = 0; i < Constants.INCREMENTAL_ITERATIONS; i++) {
for (const term of terms) {
if (search.findPrevious(term, { incremental: true })) {
foundCount++;
}
}
}
return { payloadSize: Constants.INCREMENTAL_ITERATIONS * terms.length, foundCount };
}, { fork: false }).showAverageRuntime();
});
perfContext('findNext/cold cache', () => {
let terminal: TestTerminal;
let search: SearchAddon;
before(() => {
terminal = new TestTerminal({ cols: Constants.COLS, rows: Constants.ROWS, scrollback: Constants.SCROLLBACK });
search = new SearchAddon();
search.activate(terminal);
terminal.writeSync(bufferContent);
});
new RuntimeCase('', () => {
let foundCount = 0;
for (let i = 0; i < Constants.COLD_CACHE_ITERATIONS; i++) {
// Force a fresh logical-line reconstruction and match scan each search.
(search as any)._clearMatchCache();
if (search.findNext('opencv')) {
foundCount++;
}
}
if (foundCount !== Constants.COLD_CACHE_ITERATIONS) {
throw new Error(`Expected ${Constants.COLD_CACHE_ITERATIONS} matches, got ${foundCount}`);
}
return { payloadSize: Constants.COLD_CACHE_ITERATIONS, foundCount };
}, { fork: false }).showAverageRuntime();
});
perfContext('findNext/wholeWord dense-punctuation', () => {
let terminal: TestTerminal;
let search: SearchAddon;
before(() => {
terminal = new TestTerminal({ cols: Constants.COLS, rows: Constants.ROWS, scrollback: Constants.SCROLLBACK });
search = new SearchAddon();
search.activate(terminal);
terminal.writeSync(bufferContent);
});
new RuntimeCase('', () => {
let foundCount = 0;
for (let i = 0; i < Constants.NAVIGATION_ITERATIONS; i++) {
if (search.findNext('error', { wholeWord: true })) {
foundCount++;
}
}
if (foundCount !== Constants.NAVIGATION_ITERATIONS) {
throw new Error(`Expected ${Constants.NAVIGATION_ITERATIONS} matches, got ${foundCount}`);
}
return { payloadSize: Constants.NAVIGATION_ITERATIONS, foundCount };
}, { fork: false }).showAverageRuntime();
});
perfContext('findPrevious/regex wholeWord reverse', () => {
let terminal: TestTerminal;
let search: SearchAddon;
before(() => {
terminal = new TestTerminal({ cols: Constants.COLS, rows: Constants.ROWS, scrollback: Constants.SCROLLBACK });
search = new SearchAddon();
search.activate(terminal);
terminal.writeSync(bufferContent);
});
new RuntimeCase('', () => {
let foundCount = 0;
for (let i = 0; i < Constants.NAVIGATION_ITERATIONS; i++) {
if (search.findPrevious('error', { regex: true, wholeWord: true })) {
foundCount++;
}
}
if (foundCount !== Constants.NAVIGATION_ITERATIONS) {
throw new Error(`Expected ${Constants.NAVIGATION_ITERATIONS} matches, got ${foundCount}`);
}
return { payloadSize: Constants.NAVIGATION_ITERATIONS, foundCount };
}, { fork: false }).showAverageRuntime();
});
perfContext('findNext/decorations refresh', () => {
let terminal: TestTerminal;
let search: SearchAddon;
before(() => {
terminal = new TestTerminal({ cols: Constants.COLS, rows: Constants.ROWS, scrollback: Constants.SCROLLBACK });
search = new SearchAddon({ highlightLimit: 1500 });
search.activate(terminal);
terminal.writeSync(bufferContent);
});
new RuntimeCase('', () => {
let foundCount = 0;
for (let i = 0; i < Constants.DECORATION_REFRESH_ITERATIONS; i++) {
const term = i % 2 === 0 ? 'WARN' : 'ERROR';
if (search.findNext(term, DECORATION_OPTIONS)) {
foundCount++;
}
}
if (foundCount !== Constants.DECORATION_REFRESH_ITERATIONS) {
throw new Error(`Expected ${Constants.DECORATION_REFRESH_ITERATIONS} matches, got ${foundCount}`);
}
return { payloadSize: Constants.DECORATION_REFRESH_ITERATIONS, foundCount };
}, { fork: false }).showAverageRuntime();
});
});
@@ -1,13 +0,0 @@
{
"APP_PATH": ".benchmark",
"evalConfig": {
"tolerance": {
"*": [0.9, 1.2],
"SearchAddon.benchmark.js.*.averageRuntime.mean": [0.93, 1.12]
},
"skip": [
"*.median",
"*.runs"
]
}
}
@@ -1,29 +0,0 @@
{
"compilerOptions": {
"lib": [
"dom",
"es2021"
],
"rootDir": "..",
"outDir": "../out-benchmark",
"types": ["../../../node_modules/@types/node"],
"moduleResolution": "node",
"strict": false,
"target": "es2021",
"module": "commonjs",
"paths": {
"common/*": ["../../../src/common/*"],
"browser/*": ["../../../src/browser/*"],
"SearchAddon": ["../src/SearchAddon"],
"@xterm/addon-search": [
"../typings/addon-search.d.ts"
]
}
},
"include": ["../**/*", "../../../typings/xterm.d.ts"],
"exclude": ["../../../**/*test.ts", "../../**/*api.ts"],
"references": [
{ "path": "../../../src/common" },
{ "path": "../../../src/browser" }
]
}
+1 -4
View File
@@ -19,9 +19,6 @@
"prepackage": "../../node_modules/.bin/tsgo -p .",
"package": "../../node_modules/.bin/webpack",
"prepublishOnly": "npm run package",
"start": "node ../../demo/start",
"benchmark": "NODE_PATH=../../out:./out:./out-benchmark/ ../../node_modules/.bin/xterm-benchmark -r 5 -c benchmark/benchmark.json out-benchmark/benchmark/*benchmark.js",
"benchmark-baseline": "NODE_PATH=../../out:./out:./out-benchmark/ ../../node_modules/.bin/xterm-benchmark -r 10 -c benchmark/benchmark.json --baseline out-benchmark/benchmark/*benchmark.js",
"benchmark-eval": "NODE_PATH=../../out:./out:./out-benchmark/ ../../node_modules/.bin/xterm-benchmark -r 10 -c benchmark/benchmark.json --eval -F out-benchmark/benchmark/*benchmark.js"
"start": "node ../../demo/start"
}
}
@@ -0,0 +1,158 @@
/**
* Copyright (c) 2017 The xterm.js authors. All rights reserved.
* @license MIT
*/
import type { Terminal, IDisposable, IDecoration } from '@xterm/xterm';
import type { ISearchDecorationOptions } from '@xterm/addon-search';
import { dispose, Disposable, toDisposable } from 'common/Lifecycle';
import type { ISearchResult } from './SearchEngine';
/**
* Interface for managing a highlight decoration.
*/
interface IHighlight extends IDisposable {
decoration: IDecoration;
match: ISearchResult;
}
/**
* Interface for managing multiple decorations for a single match.
*/
interface IMultiHighlight extends IDisposable {
decorations: IDecoration[];
match: ISearchResult;
}
/**
* Manages visual decorations for search results including highlighting and active selection
* indicators. This class handles the creation, styling, and disposal of search-related decorations.
*/
export class DecorationManager extends Disposable {
private _highlightDecorations: IHighlight[] = [];
private _highlightedLines: Set<number> = new Set();
constructor(private readonly _terminal: Terminal) {
super();
this._register(toDisposable(() => this.clearHighlightDecorations()));
}
/**
* Creates decorations for all provided search results.
* @param results The search results to create decorations for.
* @param options The decoration options.
*/
public createHighlightDecorations(results: ISearchResult[], options: ISearchDecorationOptions): void {
this.clearHighlightDecorations();
for (const match of results) {
const decorations = this._createResultDecorations(match, options, false);
if (decorations) {
for (const decoration of decorations) {
this._storeDecoration(decoration, match);
}
}
}
}
/**
* Creates decorations for the currently active search result.
* @param result The active search result.
* @param options The decoration options.
* @returns The multi-highlight decoration or undefined if creation failed.
*/
public createActiveDecoration(result: ISearchResult, options: ISearchDecorationOptions): IMultiHighlight | undefined {
const decorations = this._createResultDecorations(result, options, true);
if (decorations) {
return { decorations, match: result, dispose() { dispose(decorations); } };
}
return undefined;
}
/**
* Clears all highlight decorations.
*/
public clearHighlightDecorations(): void {
dispose(this._highlightDecorations);
this._highlightDecorations = [];
this._highlightedLines.clear();
}
/**
* Stores a decoration and tracks it for management.
* @param decoration The decoration to store.
* @param match The search result this decoration represents.
*/
private _storeDecoration(decoration: IDecoration, match: ISearchResult): void {
this._highlightedLines.add(decoration.marker.line);
this._highlightDecorations.push({ decoration, match, dispose() { decoration.dispose(); } });
}
/**
* Applies styles to the decoration when it is rendered.
* @param element The decoration's element.
* @param borderColor The border color to apply.
* @param isActiveResult Whether the element is part of the active search result.
*/
private _applyStyles(element: HTMLElement, borderColor: string | undefined, isActiveResult: boolean): void {
if (!element.classList.contains('xterm-find-result-decoration')) {
element.classList.add('xterm-find-result-decoration');
if (borderColor) {
element.style.outline = `1px solid ${borderColor}`;
}
}
if (isActiveResult) {
element.classList.add('xterm-find-active-result-decoration');
}
}
/**
* Creates a decoration for the result and applies styles
* @param result the search result for which to create the decoration
* @param options the options for the decoration
* @param isActiveResult whether this is the currently active result
* @returns the decorations or undefined if the marker has already been disposed of
*/
private _createResultDecorations(result: ISearchResult, options: ISearchDecorationOptions, isActiveResult: boolean): IDecoration[] | undefined {
// Gather decoration ranges for this match as it could wrap
const decorationRanges: [number, number, number][] = [];
let currentCol = result.col;
let remainingSize = result.size;
let markerOffset = -this._terminal.buffer.active.baseY - this._terminal.buffer.active.cursorY + result.row;
while (remainingSize > 0) {
const amountThisRow = Math.min(this._terminal.cols - currentCol, remainingSize);
decorationRanges.push([markerOffset, currentCol, amountThisRow]);
currentCol = 0;
remainingSize -= amountThisRow;
markerOffset++;
}
// Create the decorations
const decorations: IDecoration[] = [];
for (const range of decorationRanges) {
const marker = this._terminal.registerMarker(range[0]);
const decoration = this._terminal.registerDecoration({
marker,
x: range[1],
width: range[2],
layer: isActiveResult ? 'top' : 'bottom',
backgroundColor: isActiveResult ? options.activeMatchBackground : options.matchBackground,
overviewRulerOptions: this._highlightedLines.has(marker.line) ? undefined : {
color: isActiveResult ? options.activeMatchColorOverviewRuler : options.matchOverviewRuler,
position: 'center'
}
});
if (decoration) {
const disposables: IDisposable[] = [];
disposables.push(marker);
disposables.push(decoration.onRender((e) => this._applyStyles(e, isActiveResult ? options.activeMatchBorder : options.matchBorder, false)));
disposables.push(decoration.onDispose(() => dispose(disposables)));
decorations.push(decoration);
}
}
return decorations.length === 0 ? undefined : decorations;
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,708 @@
/**
* Copyright (c) 2024 The xterm.js authors. All rights reserved.
* @license MIT
*/
import { assert } from 'chai';
import { SearchEngine } from './SearchEngine';
import { SearchLineCache } from './SearchLineCache';
import { Terminal } from 'browser/public/Terminal';
import type { ISearchOptions } from '@xterm/addon-search';
import { DisposableStore } from 'common/Lifecycle';
function writeP(terminal: Terminal, data: string): Promise<void> {
return new Promise(r => terminal.write(data, r));
}
describe('SearchEngine', () => {
let store: DisposableStore;
let terminal: Terminal;
let lineCache: SearchLineCache;
let searchEngine: SearchEngine;
beforeEach(() => {
store = new DisposableStore();
terminal = store.add(new Terminal({ cols: 80, rows: 24 }));
lineCache = store.add(new SearchLineCache(terminal));
searchEngine = new SearchEngine(terminal, lineCache);
});
afterEach(() => {
store.dispose();
});
describe('find', () => {
it('should return undefined for empty search term', async () => {
await writeP(terminal, 'Hello World');
assert.strictEqual(searchEngine.find('', 0, 0), undefined);
});
it('should find basic text in terminal content', async () => {
await writeP(terminal, 'Hello World');
assert.deepStrictEqual(searchEngine.find('World', 0, 0), {
term: 'World',
col: 6,
row: 0,
size: 5
});
});
it('should find text starting from specified position', async () => {
await writeP(terminal, 'Hello Hello Hello');
assert.deepStrictEqual(searchEngine.find('Hello', 0, 7), {
term: 'Hello',
col: 12,
row: 0,
size: 5
});
});
it('should search across multiple rows', async () => {
await writeP(terminal, 'Line 1\r\nLine 2 target\r\nLine 3');
assert.deepStrictEqual(searchEngine.find('target', 0, 0), {
term: 'target',
col: 7,
row: 1,
size: 6
});
});
it('should return undefined when text is not found', async () => {
await writeP(terminal, 'Hello World');
assert.strictEqual(searchEngine.find('NotFound', 0, 0), undefined);
});
it('should throw error for invalid column position', async () => {
await writeP(terminal, 'Hello World');
assert.throws(() => {
searchEngine.find('Hello', 0, 100);
}, /Invalid col: 100 to search in terminal of 80 cols/);
});
it('should handle search starting from last column', async () => {
await writeP(terminal, 'Hello World');
assert.strictEqual(searchEngine.find('Hello', 0, 79), undefined);
});
it('should handle search from middle of match', async () => {
await writeP(terminal, 'Hello World');
assert.strictEqual(searchEngine.find('llo', 0, 3), undefined); // Should not find partial match that starts before search position
});
});
describe('search options', () => {
describe('caseSensitive', () => {
it('should find text with case-insensitive search (default)', async () => {
await writeP(terminal, 'Hello WORLD');
assert.deepStrictEqual(searchEngine.find('world', 0, 0), {
term: 'world',
col: 6,
row: 0,
size: 5
});
});
it('should find text with case-sensitive search when enabled', async () => {
await writeP(terminal, 'Hello WORLD');
assert.deepStrictEqual(searchEngine.find('WORLD', 0, 0, { caseSensitive: true }), {
term: 'WORLD',
col: 6,
row: 0,
size: 5
});
});
it('should not find text with case-sensitive search when case differs', async () => {
await writeP(terminal, 'Hello WORLD');
assert.strictEqual(searchEngine.find('world', 0, 0, { caseSensitive: true }), undefined);
});
});
describe('wholeWord', () => {
it('should find whole word when enabled', async () => {
await writeP(terminal, 'Hello world wonderful');
assert.deepStrictEqual(searchEngine.find('world', 0, 0, { wholeWord: true }), {
term: 'world',
col: 6,
row: 0,
size: 5
});
});
it('should not find partial word when wholeWord is enabled', async () => {
await writeP(terminal, 'Hello wonderful');
assert.strictEqual(searchEngine.find('world', 0, 0, { wholeWord: true }), undefined);
});
it('should find word at beginning of line with wholeWord', async () => {
await writeP(terminal, 'world is great');
assert.deepStrictEqual(searchEngine.find('world', 0, 0, { wholeWord: true }), {
term: 'world',
col: 0,
row: 0,
size: 5
});
});
it('should find word at end of line with wholeWord', async () => {
await writeP(terminal, 'hello world');
assert.deepStrictEqual(searchEngine.find('world', 0, 0, { wholeWord: true }), {
term: 'world',
col: 6,
row: 0,
size: 5
});
});
it('should handle word boundaries with punctuation', async () => {
await writeP(terminal, 'hello,world!test');
assert.deepStrictEqual(searchEngine.find('world', 0, 0, { wholeWord: true }), {
term: 'world',
col: 6,
row: 0,
size: 5
});
});
it('should not match when not whole word', async () => {
await writeP(terminal, 'helloworld');
assert.strictEqual(searchEngine.find('world', 0, 0, { wholeWord: true }), undefined);
});
});
describe('regex', () => {
it('should find text using simple regex pattern', async () => {
await writeP(terminal, 'Hello 123 World');
assert.deepStrictEqual(searchEngine.find('[0-9]+', 0, 0, { regex: true }), {
term: '123',
col: 6,
row: 0,
size: 3
});
});
it('should find text using regex with case-insensitive flag', async () => {
await writeP(terminal, 'Hello WORLD');
assert.deepStrictEqual(searchEngine.find('world', 0, 0, { regex: true, caseSensitive: false }), {
term: 'WORLD',
col: 6,
row: 0,
size: 5
});
});
it('should find text using regex with case-sensitive flag', async () => {
await writeP(terminal, 'Hello WORLD world');
assert.deepStrictEqual(searchEngine.find('WORLD', 0, 0, { regex: true, caseSensitive: true }), {
term: 'WORLD',
col: 6,
row: 0,
size: 5
});
});
it('should handle complex regex patterns', async () => {
await writeP(terminal, 'Email: test@example.com and another@domain.org');
assert.deepStrictEqual(searchEngine.find('[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}', 0, 0, { regex: true }), {
term: 'test@example.com',
col: 7,
row: 0,
size: 16
});
});
it('should return undefined for invalid regex pattern', async () => {
await writeP(terminal, 'Hello World');
// Invalid regex should be handled gracefully
assert.throws(() => {
searchEngine.find('[invalid', 0, 0, { regex: true });
}, /Invalid regular expression/);
});
it('should handle empty regex matches', async () => {
await writeP(terminal, 'Hello World');
assert.strictEqual(searchEngine.find('.*?', 0, 0, { regex: true }), undefined); // Empty matches should be ignored
});
});
describe('combined options', () => {
it('should handle regex + caseSensitive combination', async () => {
await writeP(terminal, 'Hello WORLD world');
assert.deepStrictEqual(searchEngine.find('[A-Z]+', 0, 0, { regex: true, caseSensitive: true }), {
term: 'H',
col: 0,
row: 0,
size: 1
});
});
it('should handle wholeWord + caseSensitive combination', async () => {
await writeP(terminal, 'Hello WORLD wonderful');
const result1 = searchEngine.find('WORLD', 0, 0, { wholeWord: true, caseSensitive: true });
assert.deepStrictEqual(result1, {
term: 'WORLD',
col: 6,
row: 0,
size: 5
});
const result2 = searchEngine.find('world', 0, 0, { wholeWord: true, caseSensitive: true });
assert.strictEqual(result2, undefined);
});
});
});
describe('findNextWithSelection', () => {
it('should return undefined for empty search term', async () => {
await writeP(terminal, 'Hello World');
assert.strictEqual(searchEngine.findNextWithSelection(''), undefined);
});
it('should find first occurrence when no selection exists', async () => {
await writeP(terminal, 'Hello World Hello');
assert.deepStrictEqual(searchEngine.findNextWithSelection('Hello'), {
term: 'Hello',
col: 0,
row: 0,
size: 5
});
});
it('should find next occurrence after current selection', async () => {
await writeP(terminal, 'Hello World Hello Again');
// Mock the getSelectionPosition to return a selection at first "Hello"
terminal.getSelectionPosition = () => ({ start: { x: 0, y: 0 }, end: { x: 5, y: 0 } });
assert.deepStrictEqual(searchEngine.findNextWithSelection('Hello', undefined, 'Hello'), {
term: 'Hello',
col: 12,
row: 0,
size: 5
});
});
it('should wrap around to beginning when reaching end', async () => {
await writeP(terminal, 'Hello World Hello');
// Mock selection at the end
terminal.getSelectionPosition = () => ({ start: { x: 12, y: 0 }, end: { x: 17, y: 0 } });
assert.deepStrictEqual(searchEngine.findNextWithSelection('Hello', undefined, 'Hello'), {
term: 'Hello',
col: 0,
row: 0,
size: 5
}); // Should wrap to first occurrence
});
it('should wrap across multiple rows', async () => {
await writeP(terminal, 'Line 1 test\r\nLine 2\r\nLine 3 test');
// Mock selection at first "test"
terminal.getSelectionPosition = () => ({ start: { x: 7, y: 0 }, end: { x: 11, y: 0 } });
assert.deepStrictEqual(searchEngine.findNextWithSelection('test', undefined, 'test'), {
term: 'test',
col: 7,
row: 2,
size: 4
});
});
it('should return same selection if only one match exists', async () => {
await writeP(terminal, 'Hello World');
// Mock selection at "Hello"
terminal.getSelectionPosition = () => ({ start: { x: 0, y: 0 }, end: { x: 5, y: 0 } });
assert.deepStrictEqual(searchEngine.findNextWithSelection('Hello'), {
term: 'Hello',
col: 0,
row: 0,
size: 5
});
});
it('should clear selection and return undefined when term not found', async () => {
await writeP(terminal, 'Hello World');
assert.strictEqual(searchEngine.findNextWithSelection('NotFound'), undefined);
});
});
describe('findPreviousWithSelection', () => {
it('should return undefined for empty search term', async () => {
await writeP(terminal, 'Hello World');
assert.strictEqual(searchEngine.findPreviousWithSelection(''), undefined);
});
it('should find last occurrence when no selection exists', async () => {
await writeP(terminal, 'Hello World Hello');
assert.deepStrictEqual(searchEngine.findPreviousWithSelection('Hello'), {
term: 'Hello',
col: 12,
row: 0,
size: 5
});
});
it('should find previous occurrence before current selection', async () => {
await writeP(terminal, 'Hello World Hello Again');
// Mock selection at second "Hello"
terminal.getSelectionPosition = () => ({ start: { x: 12, y: 0 }, end: { x: 17, y: 0 } });
const result = searchEngine.findPreviousWithSelection('Hello');
assert.notStrictEqual(result, undefined);
// It may find the same selection first due to expansion attempt
assert.strictEqual(typeof result!.col, 'number');
assert.strictEqual(result!.row, 0);
});
it('should wrap around to end when reaching beginning', async () => {
await writeP(terminal, 'Hello World Hello');
// Mock selection at first "Hello"
terminal.getSelectionPosition = () => ({ start: { x: 0, y: 0 }, end: { x: 5, y: 0 } });
const result = searchEngine.findPreviousWithSelection('Hello');
assert.notStrictEqual(result, undefined);
// Due to the expansion attempt, it may find the same Hello first
assert.strictEqual(typeof result!.col, 'number');
assert.strictEqual(result!.row, 0);
});
it('should work across multiple rows in reverse', async () => {
await writeP(terminal, 'test Line 1\r\nLine 2\r\ntest Line 3');
// Mock selection at last "test"
terminal.getSelectionPosition = () => ({ start: { x: 0, y: 2 }, end: { x: 4, y: 2 } });
const result = searchEngine.findPreviousWithSelection('test');
assert.notStrictEqual(result, undefined);
// The algorithm will find the current selection first due to expansion attempt
assert.strictEqual(typeof result!.row, 'number');
assert.strictEqual(typeof result!.col, 'number');
});
it('should handle selection expansion correctly', async () => {
await writeP(terminal, 'Hello World Hello');
// Mock selection at first "Hello"
terminal.getSelectionPosition = () => ({ start: { x: 0, y: 0 }, end: { x: 5, y: 0 } });
const result = searchEngine.findPreviousWithSelection('Hello');
assert.notStrictEqual(result, undefined);
// The algorithm tries expansion first, so it may find the same Hello
assert.strictEqual(typeof result!.col, 'number');
assert.strictEqual(result!.row, 0);
});
it('should clear selection and return undefined when term not found', async () => {
await writeP(terminal, 'Hello World');
assert.strictEqual(searchEngine.findPreviousWithSelection('NotFound'), undefined);
});
});
describe('edge cases and error handling', () => {
describe('unicode and special characters', () => {
it('should handle unicode characters correctly', async () => {
await writeP(terminal, 'Hello 世界 World');
assert.deepStrictEqual(searchEngine.find('世界', 0, 0), {
term: '世界',
col: 6,
row: 0,
size: 4
});
});
it('should handle wide characters', async () => {
await writeP(terminal, '中文测试');
assert.deepStrictEqual(searchEngine.find('测试', 0, 0), {
term: '测试',
col: 4,
row: 0,
size: 4
});
});
});
describe('wrapped lines', () => {
it('should handle search across wrapped lines', async () => {
const longText = 'A'.repeat(100) + 'target' + 'B'.repeat(50);
await writeP(terminal, longText);
assert.deepStrictEqual(searchEngine.find('target', 0, 0), {
term: 'target',
col: 20,
row: 1,
size: 6
});
});
it('should handle wrapped lines with unicode', async () => {
const longText = '中'.repeat(50) + 'target' + '文'.repeat(30);
await writeP(terminal, longText);
assert.deepStrictEqual(searchEngine.find('target', 0, 0), {
term: 'target',
col: 20,
row: 1,
size: 6
});
});
it('should skip wrapped lines correctly in findInLine', async () => {
const longText = 'A'.repeat(200);
await writeP(terminal, longText + '\r\nNext line with target');
assert.deepStrictEqual(searchEngine.find('target', 0, 0), {
term: 'target',
col: 15,
row: 3,
size: 6
});
});
});
describe('buffer boundaries', () => {
it('should handle empty buffer gracefully', () => {
assert.strictEqual(searchEngine.find('anything', 0, 0), undefined);
});
it('should handle search beyond buffer size', () => {
assert.strictEqual(searchEngine.find('test', 1000, 0), undefined);
});
});
describe('invalid inputs', () => {
it('should handle undefined search options gracefully', async () => {
await writeP(terminal, 'Hello World');
assert.deepStrictEqual(searchEngine.find('Hello', 0, 0, undefined), {
term: 'Hello',
col: 0,
row: 0,
size: 5
});
});
it('should handle negative start positions', async () => {
await writeP(terminal, 'Hello World');
assert.deepStrictEqual(searchEngine.find('Hello', -1, -1), {
term: 'Hello',
col: 0,
row: 0,
size: 5
});
});
it('should handle search options with undefined properties', async () => {
await writeP(terminal, 'Hello World');
const options: ISearchOptions = {
caseSensitive: undefined,
regex: undefined,
wholeWord: undefined
};
assert.deepStrictEqual(searchEngine.find('Hello', 0, 0, options), {
term: 'Hello',
col: 0,
row: 0,
size: 5
});
});
});
});
describe('private method behaviors (tested indirectly)', () => {
describe('_isWholeWord behavior', () => {
it('should recognize word boundaries with various punctuation', async () => {
await writeP(terminal, 'word1 word2,word3(word4)word5[word6]word7{word8}');
const tests = [
{ term: 'word1', expected: true },
{ term: 'word2', expected: true },
{ term: 'word3', expected: true },
{ term: 'word4', expected: true },
{ term: 'word5', expected: true },
{ term: 'word6', expected: true },
{ term: 'word7', expected: true },
{ term: 'word8', expected: true }
];
for (const test of tests) {
const result = searchEngine.find(test.term, 0, 0, { wholeWord: true });
if (test.expected) {
assert.notStrictEqual(result, undefined, `Should find whole word: ${test.term}`);
} else {
assert.strictEqual(result, undefined, `Should not find non-whole word: ${test.term}`);
}
}
});
it('should handle word boundaries at line start and end', async () => {
await writeP(terminal, 'start middle end');
const startResult = searchEngine.find('start', 0, 0, { wholeWord: true });
assert.deepStrictEqual(startResult, {
term: 'start',
col: 0,
row: 0,
size: 5
});
const endResult = searchEngine.find('end', 0, 0, { wholeWord: true });
assert.deepStrictEqual(endResult, {
term: 'end',
col: 13,
row: 0,
size: 3
});
const middleResult = searchEngine.find('middle', 0, 0, { wholeWord: true });
assert.deepStrictEqual(middleResult, {
term: 'middle',
col: 6,
row: 0,
size: 6
});
});
});
describe('buffer offset calculations', () => {
it('should handle wide character offset calculations', async () => {
await writeP(terminal, '中文 test 测试');
const result = searchEngine.find('test', 0, 0);
assert.notStrictEqual(result, undefined);
assert.strictEqual(result!.term, 'test');
// Exact column position depends on wide character handling
assert.strictEqual(typeof result!.col, 'number');
});
});
describe('string to buffer size conversions', () => {
it('should correctly calculate size for simple text', async () => {
await writeP(terminal, 'Hello World');
assert.deepStrictEqual(searchEngine.find('World', 0, 0), {
term: 'World',
col: 6,
row: 0,
size: 5
});
});
it('should correctly calculate size for unicode text', async () => {
await writeP(terminal, 'Hello 世界');
const result = searchEngine.find('世界', 0, 0);
assert.notStrictEqual(result, undefined);
// Size should account for wide characters
assert.strictEqual(typeof result!.size, 'number');
assert.strictEqual(result!.size >= 2, true);
});
it('should handle size calculation across wrapped lines', async () => {
const longMatch = 'A'.repeat(100);
await writeP(terminal, longMatch);
const result = searchEngine.find(longMatch, 0, 0);
assert.notStrictEqual(result, undefined);
assert.strictEqual(result!.size >= 100, true);
});
});
});
describe('integration with SearchLineCache', () => {
it('should use cache for line translation', async () => {
await writeP(terminal, 'Hello World');
// Initialize cache
lineCache.initLinesCache();
const result1 = searchEngine.find('World', 0, 0);
const result2 = searchEngine.find('World', 0, 0);
assert.notStrictEqual(result1, undefined);
assert.notStrictEqual(result2, undefined);
assert.deepStrictEqual(result1, result2);
});
it('should handle cache misses gracefully', async () => {
await writeP(terminal, 'Hello World');
// Don't initialize cache
assert.deepStrictEqual(searchEngine.find('World', 0, 0), {
term: 'World',
col: 6,
row: 0,
size: 5
});
});
it('should work correctly with cache invalidation', async () => {
await writeP(terminal, 'Initial text');
lineCache.initLinesCache();
const result1 = searchEngine.find('Initial', 0, 0);
assert.notStrictEqual(result1, undefined);
// Change terminal content which should invalidate cache
await writeP(terminal, '\r\nNew line');
const result2 = searchEngine.find('New', 0, 0);
assert.deepStrictEqual(result2, {
term: 'New',
col: 0,
row: 1,
size: 3
});
});
});
});
+392
View File
@@ -0,0 +1,392 @@
/**
* Copyright (c) 2017 The xterm.js authors. All rights reserved.
* @license MIT
*/
import type { Terminal } from '@xterm/xterm';
import type { ISearchOptions } from '@xterm/addon-search';
import type { SearchLineCache } from './SearchLineCache';
/**
* Represents the position to start a search from.
*/
interface ISearchPosition {
startCol: number;
startRow: number;
}
/**
* Represents a search result with its position and content.
*/
export interface ISearchResult {
term: string;
col: number;
row: number;
size: number;
}
/**
* Configuration constants for the search engine functionality.
*/
const enum Constants {
/**
* Characters that are considered non-word characters for search boundary detection. These
* characters are used to determine word boundaries when performing whole-word searches. Includes
* common punctuation, symbols, and whitespace characters.
*/
NON_WORD_CHARACTERS = ' ~!@#$%^&*()+`-=[]{}|\\;:"\',./<>?'
}
/**
* Core search engine that handles finding text within terminal content.
* This class is responsible for the actual search algorithms and position calculations.
*/
export class SearchEngine {
constructor(
private readonly _terminal: Terminal,
private readonly _lineCache: SearchLineCache
) {}
/**
* Find the first occurrence of a term starting from a specific position.
* @param term The search term.
* @param startRow The row to start searching from.
* @param startCol The column to start searching from.
* @param searchOptions Search options.
* @returns The search result if found, undefined otherwise.
*/
public find(term: string, startRow: number, startCol: number, searchOptions?: ISearchOptions): ISearchResult | undefined {
if (!term || term.length === 0) {
this._terminal.clearSelection();
return undefined;
}
if (startCol > this._terminal.cols) {
throw new Error(`Invalid col: ${startCol} to search in terminal of ${this._terminal.cols} cols`);
}
this._lineCache.initLinesCache();
const searchPosition: ISearchPosition = {
startRow,
startCol
};
// Search startRow
let result = this._findInLine(term, searchPosition, searchOptions);
// Search from startRow + 1 to end
if (!result) {
for (let y = startRow + 1; y < this._terminal.buffer.active.baseY + this._terminal.rows; y++) {
searchPosition.startRow = y;
searchPosition.startCol = 0;
result = this._findInLine(term, searchPosition, searchOptions);
if (result) {
break;
}
}
}
return result;
}
/**
* Find the next occurrence of a term with wrapping and selection management.
* @param term The search term.
* @param searchOptions Search options.
* @param cachedSearchTerm The cached search term to determine incremental behavior.
* @returns The search result if found, undefined otherwise.
*/
public findNextWithSelection(term: string, searchOptions?: ISearchOptions, cachedSearchTerm?: string): ISearchResult | undefined {
if (!term || term.length === 0) {
this._terminal.clearSelection();
return undefined;
}
const prevSelectedPos = this._terminal.getSelectionPosition();
this._terminal.clearSelection();
let startCol = 0;
let startRow = 0;
if (prevSelectedPos) {
if (cachedSearchTerm === term) {
startCol = prevSelectedPos.end.x;
startRow = prevSelectedPos.end.y;
} else {
startCol = prevSelectedPos.start.x;
startRow = prevSelectedPos.start.y;
}
}
this._lineCache.initLinesCache();
const searchPosition: ISearchPosition = {
startRow,
startCol
};
// Search startRow
let result = this._findInLine(term, searchPosition, searchOptions);
// Search from startRow + 1 to end
if (!result) {
for (let y = startRow + 1; y < this._terminal.buffer.active.baseY + this._terminal.rows; y++) {
searchPosition.startRow = y;
searchPosition.startCol = 0;
result = this._findInLine(term, searchPosition, searchOptions);
if (result) {
break;
}
}
}
// If we hit the bottom and didn't search from the very top wrap back up
if (!result && startRow !== 0) {
for (let y = 0; y < startRow; y++) {
searchPosition.startRow = y;
searchPosition.startCol = 0;
result = this._findInLine(term, searchPosition, searchOptions);
if (result) {
break;
}
}
}
// If there is only one result, wrap back and return selection if it exists.
if (!result && prevSelectedPos) {
searchPosition.startRow = prevSelectedPos.start.y;
searchPosition.startCol = 0;
result = this._findInLine(term, searchPosition, searchOptions);
}
return result;
}
/**
* Find the previous occurrence of a term with wrapping and selection management.
* @param term The search term.
* @param searchOptions Search options.
* @param cachedSearchTerm The cached search term to determine if expansion should occur.
* @returns The search result if found, undefined otherwise.
*/
public findPreviousWithSelection(term: string, searchOptions?: ISearchOptions, cachedSearchTerm?: string): ISearchResult | undefined {
if (!term || term.length === 0) {
this._terminal.clearSelection();
return undefined;
}
const prevSelectedPos = this._terminal.getSelectionPosition();
this._terminal.clearSelection();
let startRow = this._terminal.buffer.active.baseY + this._terminal.rows - 1;
let startCol = this._terminal.cols;
const isReverseSearch = true;
this._lineCache.initLinesCache();
const searchPosition: ISearchPosition = {
startRow,
startCol
};
let result: ISearchResult | undefined;
if (prevSelectedPos) {
searchPosition.startRow = startRow = prevSelectedPos.start.y;
searchPosition.startCol = startCol = prevSelectedPos.start.x;
if (cachedSearchTerm !== term) {
// Try to expand selection to right first.
result = this._findInLine(term, searchPosition, searchOptions, false);
if (!result) {
// If selection was not able to be expanded to the right, then try reverse search
searchPosition.startRow = startRow = prevSelectedPos.end.y;
searchPosition.startCol = startCol = prevSelectedPos.end.x;
}
}
}
result ??= this._findInLine(term, searchPosition, searchOptions, isReverseSearch);
// Search from startRow - 1 to top
if (!result) {
searchPosition.startCol = Math.max(searchPosition.startCol, this._terminal.cols);
for (let y = startRow - 1; y >= 0; y--) {
searchPosition.startRow = y;
result = this._findInLine(term, searchPosition, searchOptions, isReverseSearch);
if (result) {
break;
}
}
}
// If we hit the top and didn't search from the very bottom wrap back down
if (!result && startRow !== (this._terminal.buffer.active.baseY + this._terminal.rows - 1)) {
for (let y = (this._terminal.buffer.active.baseY + this._terminal.rows - 1); y >= startRow; y--) {
searchPosition.startRow = y;
result = this._findInLine(term, searchPosition, searchOptions, isReverseSearch);
if (result) {
break;
}
}
}
return result;
}
/**
* A found substring is a whole word if it doesn't have an alphanumeric character directly
* adjacent to it.
* @param searchIndex starting index of the potential whole word substring
* @param line entire string in which the potential whole word was found
* @param term the substring that starts at searchIndex
*/
private _isWholeWord(searchIndex: number, line: string, term: string): boolean {
return ((searchIndex === 0) || (Constants.NON_WORD_CHARACTERS.includes(line[searchIndex - 1]))) &&
(((searchIndex + term.length) === line.length) || (Constants.NON_WORD_CHARACTERS.includes(line[searchIndex + term.length])));
}
/**
* Searches a line for a search term. Takes the provided terminal line and searches the text line,
* which may contain subsequent terminal lines if the text is wrapped. If the provided line number
* is part of a wrapped text line that started on an earlier line then it is skipped since it will
* be properly searched when the terminal line that the text starts on is searched.
* @param term The search term.
* @param searchPosition The position to start the search.
* @param searchOptions Search options.
* @param isReverseSearch Whether the search should start from the right side of the terminal and
* search to the left.
* @returns The search result if it was found.
*/
private _findInLine(term: string, searchPosition: ISearchPosition, searchOptions: ISearchOptions = {}, isReverseSearch: boolean = false): ISearchResult | undefined {
const row = searchPosition.startRow;
const col = searchPosition.startCol;
// Ignore wrapped lines, only consider on unwrapped line (first row of command string).
const firstLine = this._terminal.buffer.active.getLine(row);
if (firstLine?.isWrapped) {
if (isReverseSearch) {
searchPosition.startCol += this._terminal.cols;
return;
}
// This will iterate until we find the line start.
// When we find it, we will search using the calculated start column.
searchPosition.startRow--;
searchPosition.startCol += this._terminal.cols;
return this._findInLine(term, searchPosition, searchOptions);
}
let cache = this._lineCache.getLineFromCache(row);
if (!cache) {
cache = this._lineCache.translateBufferLineToStringWithWrap(row, true);
this._lineCache.setLineInCache(row, cache);
}
const [stringLine, offsets] = cache;
const offset = this._bufferColsToStringOffset(row, col);
let searchTerm = term;
let searchStringLine = stringLine;
if (!searchOptions.regex) {
searchTerm = searchOptions.caseSensitive ? term : term.toLowerCase();
searchStringLine = searchOptions.caseSensitive ? stringLine : stringLine.toLowerCase();
}
let resultIndex = -1;
if (searchOptions.regex) {
const searchRegex = RegExp(searchTerm, searchOptions.caseSensitive ? 'g' : 'gi');
let foundTerm: RegExpExecArray | null;
if (isReverseSearch) {
// This loop will get the resultIndex of the _last_ regex match in the range 0..offset
while (foundTerm = searchRegex.exec(searchStringLine.slice(0, offset))) {
resultIndex = searchRegex.lastIndex - foundTerm[0].length;
term = foundTerm[0];
searchRegex.lastIndex -= (term.length - 1);
}
} else {
foundTerm = searchRegex.exec(searchStringLine.slice(offset));
if (foundTerm && foundTerm[0].length > 0) {
resultIndex = offset + (searchRegex.lastIndex - foundTerm[0].length);
term = foundTerm[0];
}
}
} else {
if (isReverseSearch) {
if (offset - searchTerm.length >= 0) {
resultIndex = searchStringLine.lastIndexOf(searchTerm, offset - searchTerm.length);
}
} else {
resultIndex = searchStringLine.indexOf(searchTerm, offset);
}
}
if (resultIndex >= 0) {
if (searchOptions.wholeWord && !this._isWholeWord(resultIndex, searchStringLine, term)) {
return;
}
// Adjust the row number and search index if needed since a "line" of text can span multiple
// rows
let startRowOffset = 0;
while (startRowOffset < offsets.length - 1 && resultIndex >= offsets[startRowOffset + 1]) {
startRowOffset++;
}
let endRowOffset = startRowOffset;
while (endRowOffset < offsets.length - 1 && resultIndex + term.length >= offsets[endRowOffset + 1]) {
endRowOffset++;
}
const startColOffset = resultIndex - offsets[startRowOffset];
const endColOffset = resultIndex + term.length - offsets[endRowOffset];
const startColIndex = this._stringLengthToBufferSize(row + startRowOffset, startColOffset);
const endColIndex = this._stringLengthToBufferSize(row + endRowOffset, endColOffset);
const size = endColIndex - startColIndex + this._terminal.cols * (endRowOffset - startRowOffset);
return {
term,
col: startColIndex,
row: row + startRowOffset,
size
};
}
}
private _stringLengthToBufferSize(row: number, offset: number): number {
const line = this._terminal.buffer.active.getLine(row);
if (!line) {
return 0;
}
for (let i = 0; i < offset; i++) {
const cell = line.getCell(i);
if (!cell) {
break;
}
// Adjust the searchIndex to normalize emoji into single chars
const char = cell.getChars();
if (char.length > 1) {
offset -= char.length - 1;
}
// Adjust the searchIndex for empty characters following wide unicode
// chars (eg. CJK)
const nextCell = line.getCell(i + 1);
if (nextCell && nextCell.getWidth() === 0) {
offset++;
}
}
return offset;
}
private _bufferColsToStringOffset(startRow: number, cols: number): number {
let lineIndex = startRow;
let offset = 0;
let line = this._terminal.buffer.active.getLine(lineIndex);
while (cols > 0 && line) {
for (let i = 0; i < cols && i < this._terminal.cols; i++) {
const cell = line.getCell(i);
if (!cell) {
break;
}
if (cell.getWidth()) {
// Treat null characters as whitespace to align with the translateToString API
offset += cell.getCode() === 0 ? 1 : cell.getChars().length;
}
}
lineIndex++;
line = this._terminal.buffer.active.getLine(lineIndex);
if (line && !line.isWrapped) {
break;
}
cols -= this._terminal.cols;
}
return offset;
}
}
@@ -0,0 +1,287 @@
/**
* Copyright (c) 2024 The xterm.js authors. All rights reserved.
* @license MIT
*/
import { assert } from 'chai';
import { SearchLineCache, LineCacheEntry } from './SearchLineCache';
import { Terminal } from 'browser/public/Terminal';
import { timeout } from 'common/Async';
function writeP(terminal: Terminal, data: string): Promise<void> {
return new Promise(r => terminal.write(data, r));
}
describe('SearchLineCache', () => {
let terminal: Terminal;
let cache: SearchLineCache;
beforeEach(() => {
terminal = new Terminal({ cols: 80, rows: 24 });
cache = new SearchLineCache(terminal);
});
afterEach(() => {
cache.dispose();
terminal.dispose();
});
describe('constructor', () => {
it('should create a SearchLineCache instance', () => {
assert.instanceOf(cache, SearchLineCache);
});
it('should start with no cache initialized', () => {
assert.equal(cache.getLineFromCache(0), undefined);
});
});
describe('initLinesCache', () => {
it('should initialize the lines cache array', () => {
cache.initLinesCache();
assert.equal(cache.getLineFromCache(0), undefined, 'cache should be initialized but empty');
});
it('should not reinitialize if cache already exists', () => {
cache.initLinesCache();
cache.setLineInCache(0, ['test', [0]]);
cache.initLinesCache();
const entry = cache.getLineFromCache(0);
assert.deepEqual(entry, ['test', [0]], 'cache should still contain the previously set entry');
});
it('should set up TTL timeout', () => {
cache.initLinesCache();
cache.setLineInCache(0, ['test', [0]]);
assert.deepEqual(cache.getLineFromCache(0), ['test', [0]], 'entry should exist after initialization');
});
});
describe('getLineFromCache', () => {
it('should return undefined when cache is not initialized', () => {
assert.equal(cache.getLineFromCache(0), undefined);
assert.equal(cache.getLineFromCache(10), undefined);
});
it('should return undefined for unset entries when cache is initialized', () => {
cache.initLinesCache();
assert.equal(cache.getLineFromCache(0), undefined);
assert.equal(cache.getLineFromCache(50), undefined);
});
it('should return cached entries', () => {
cache.initLinesCache();
const entry: LineCacheEntry = ['test content', [0]];
cache.setLineInCache(5, entry);
assert.deepEqual(cache.getLineFromCache(5), entry);
});
});
describe('setLineInCache', () => {
it('should not set entries when cache is not initialized', () => {
const entry: LineCacheEntry = ['test content', [0]];
cache.setLineInCache(0, entry);
assert.equal(cache.getLineFromCache(0), undefined);
});
it('should set entries when cache is initialized', () => {
cache.initLinesCache();
const entry: LineCacheEntry = ['test content', [0]];
cache.setLineInCache(10, entry);
assert.deepEqual(cache.getLineFromCache(10), entry);
});
it('should overwrite existing entries', () => {
cache.initLinesCache();
const entry1: LineCacheEntry = ['first content', [0]];
const entry2: LineCacheEntry = ['second content', [0]];
cache.setLineInCache(0, entry1);
assert.deepEqual(cache.getLineFromCache(0), entry1);
cache.setLineInCache(0, entry2);
assert.deepEqual(cache.getLineFromCache(0), entry2);
});
});
describe('translateBufferLineToStringWithWrap', () => {
it('should translate a single line without wrapping', async () => {
await writeP(terminal, 'Hello World');
const result = cache.translateBufferLineToStringWithWrap(0, true);
assert.equal(result[0], 'Hello World');
assert.deepEqual(result[1], [0]);
});
it('should handle trimRight parameter', async () => {
await writeP(terminal, 'Hello World ');
const resultTrimmed = cache.translateBufferLineToStringWithWrap(0, true);
const resultNotTrimmed = cache.translateBufferLineToStringWithWrap(0, false);
assert.equal(resultTrimmed[0].trimEnd(), 'Hello World');
assert.isTrue(resultNotTrimmed[0].startsWith('Hello World '));
assert.isTrue(resultNotTrimmed[0].length > resultTrimmed[0].length, 'non-trimmed result should be longer');
});
it('should handle wrapped lines', async () => {
const longText = 'A'.repeat(200);
await writeP(terminal, longText);
const result = cache.translateBufferLineToStringWithWrap(0, true);
assert.equal(result[0], longText);
assert.isTrue(result[1].length > 1, 'should have multiple offsets due to wrapping');
assert.equal(result[1][0], 0, 'first offset should be 0');
});
it('should handle wide characters', async () => {
await writeP(terminal, 'Hello 世界');
const result = cache.translateBufferLineToStringWithWrap(0, true);
assert.equal(result[0], 'Hello 世界');
assert.deepEqual(result[1], [0]);
});
it('should handle empty lines', () => {
const result = cache.translateBufferLineToStringWithWrap(0, true);
assert.equal(result[0], '');
assert.deepEqual(result[1], [0]);
});
it('should handle lines beyond buffer', () => {
const result = cache.translateBufferLineToStringWithWrap(1000, true);
assert.equal(result[0], '');
assert.deepEqual(result[1], [0]);
});
it('should handle complex wrapped content', async () => {
await writeP(terminal, 'Line 1\r\n');
await writeP(terminal, 'Line 2 with some longer content that might wrap\r\n');
await writeP(terminal, 'Line 3');
const result1 = cache.translateBufferLineToStringWithWrap(0, true);
const result2 = cache.translateBufferLineToStringWithWrap(1, true);
const result3 = cache.translateBufferLineToStringWithWrap(2, true);
assert.equal(result1[0], 'Line 1');
assert.equal(result2[0], 'Line 2 with some longer content that might wrap');
assert.equal(result3[0], 'Line 3');
});
});
describe('cache invalidation', () => {
it('should invalidate cache on line feed', async () => {
cache.initLinesCache();
cache.setLineInCache(0, ['test', [0]]);
assert.deepEqual(cache.getLineFromCache(0), ['test', [0]]);
terminal.write('test\r\n');
await timeout(10);
assert.equal(cache.getLineFromCache(0), undefined);
});
it('should invalidate cache on cursor move', async () => {
cache.initLinesCache();
cache.setLineInCache(0, ['test', [0]]);
assert.deepEqual(cache.getLineFromCache(0), ['test', [0]]);
await writeP(terminal, 'some text');
await timeout(10);
assert.equal(cache.getLineFromCache(0), undefined);
});
it('should invalidate cache on resize', async () => {
cache.initLinesCache();
cache.setLineInCache(0, ['test', [0]]);
assert.deepEqual(cache.getLineFromCache(0), ['test', [0]]);
terminal.resize(100, 30);
await timeout(10);
assert.equal(cache.getLineFromCache(0), undefined);
});
});
describe('disposal', () => {
it('should clean up resources on dispose', () => {
cache.initLinesCache();
cache.setLineInCache(0, ['test', [0]]);
assert.deepEqual(cache.getLineFromCache(0), ['test', [0]]);
cache.dispose();
assert.equal(cache.getLineFromCache(0), undefined, 'cache should be destroyed after disposal');
});
it('should be safe to dispose multiple times', () => {
cache.initLinesCache();
cache.dispose();
cache.dispose();
assert.equal(cache.getLineFromCache(0), undefined);
});
});
describe('LineCacheEntry type', () => {
it('should handle complex line offsets', () => {
const entry: LineCacheEntry = [
'A very long line that wraps multiple times across several terminal lines',
[0, 20, 40, 60]
];
cache.initLinesCache();
cache.setLineInCache(0, entry);
const retrieved = cache.getLineFromCache(0);
assert.deepEqual(retrieved, entry);
assert.equal(retrieved![0].length, 72);
assert.equal(retrieved![1].length, 4);
});
it('should handle unicode characters in cache entries', () => {
const entry: LineCacheEntry = [
'Hello 世界 🌍 测试',
[0]
];
cache.initLinesCache();
cache.setLineInCache(0, entry);
const retrieved = cache.getLineFromCache(0);
assert.deepEqual(retrieved, entry);
assert.equal(retrieved![0], 'Hello 世界 🌍 测试');
});
});
describe('integration with real terminal content', () => {
it('should correctly translate real buffer content', async () => {
await writeP(terminal, 'Hello World');
const cached = cache.translateBufferLineToStringWithWrap(0, true);
const directTranslation = terminal.buffer.active.getLine(0)?.translateToString(true) || '';
assert.equal(cached[0], directTranslation);
});
it('should handle real wrapped content correctly', async () => {
const longContent = 'This is a very long line that will definitely wrap around in an 80 column terminal and should be handled correctly by the cache';
await writeP(terminal, longContent);
const result = cache.translateBufferLineToStringWithWrap(0, true);
assert.equal(result[0], longContent);
assert.isTrue(result[1].length > 1, 'should have wrapped');
});
it('should work with real escape sequences', async () => {
await writeP(terminal, 'Before\x1b[31mRed Text\x1b[0mAfter');
const result = cache.translateBufferLineToStringWithWrap(0, true);
assert.include(result[0], 'Before');
assert.include(result[0], 'Red Text');
assert.include(result[0], 'After');
});
});
});
+136
View File
@@ -0,0 +1,136 @@
/**
* Copyright (c) 2017 The xterm.js authors. All rights reserved.
* @license MIT
*/
import type { Terminal } from '@xterm/xterm';
import { combinedDisposable, Disposable, MutableDisposable, toDisposable } from 'common/Lifecycle';
import { disposableTimeout } from 'common/Async';
export type LineCacheEntry = [
/**
* The string representation of a line (as opposed to the buffer cell representation).
*/
lineAsString: string,
/**
* The offsets where each line starts when the entry describes a wrapped line.
*/
lineOffsets: number[]
];
/**
* Configuration constants for the search line cache functionality.
*/
const enum Constants {
/**
* Time-to-live for cached search results in milliseconds. After this duration, cached search
* results will be invalidated to ensure they remain consistent with terminal content changes.
*/
LINES_CACHE_TIME_TO_LIVE = 15000
}
export class SearchLineCache extends Disposable {
/**
* translateBufferLineToStringWithWrap is a fairly expensive call.
* We memoize the calls into an array that has a time based ttl.
* _linesCache is also invalidated when the terminal cursor moves.
*/
private _linesCache: LineCacheEntry[] | undefined;
private _linesCacheTimeout = this._register(new MutableDisposable());
private _linesCacheDisposables = this._register(new MutableDisposable());
// Track access to avoid recreating a timeout on every init call which occurs once per search
// result (findNext/findPrevious -> _highlightAllMatches -> find loop).
private _lastAccessTimestamp = 0;
constructor(private readonly _terminal: Terminal) {
super();
this._register(toDisposable(() => this._destroyLinesCache()));
}
/**
* Sets up a line cache with a ttl
*/
public initLinesCache(): void {
if (!this._linesCache) {
this._linesCache = new Array(this._terminal.buffer.active.length);
this._linesCacheDisposables.value = combinedDisposable(
this._terminal.onLineFeed(() => this._destroyLinesCache()),
this._terminal.onCursorMove(() => this._destroyLinesCache()),
this._terminal.onResize(() => this._destroyLinesCache())
);
}
this._lastAccessTimestamp = Date.now();
if (!this._linesCacheTimeout.value) {
this._scheduleLinesCacheTimeout(Constants.LINES_CACHE_TIME_TO_LIVE);
}
}
private _destroyLinesCache(): void {
this._linesCache = undefined;
this._lastAccessTimestamp = 0;
this._linesCacheDisposables.clear();
this._linesCacheTimeout.clear();
}
private _scheduleLinesCacheTimeout(delay: number): void {
this._linesCacheTimeout.value = disposableTimeout(() => {
if (!this._linesCache) {
return;
}
const now = Date.now();
const elapsed = now - this._lastAccessTimestamp;
if (elapsed >= Constants.LINES_CACHE_TIME_TO_LIVE) {
this._destroyLinesCache();
return;
}
this._scheduleLinesCacheTimeout(Constants.LINES_CACHE_TIME_TO_LIVE - elapsed);
}, delay);
}
public getLineFromCache(row: number): LineCacheEntry | undefined {
return this._linesCache?.[row];
}
public setLineInCache(row: number, entry: LineCacheEntry): void {
if (this._linesCache) {
this._linesCache[row] = entry;
}
}
/**
* Translates a buffer line to a string, including subsequent lines if they are wraps.
* Wide characters will count as two columns in the resulting string. This
* function is useful for getting the actual text underneath the raw selection
* position.
* @param lineIndex The index of the line being translated.
* @param trimRight Whether to trim whitespace to the right.
*/
public translateBufferLineToStringWithWrap(lineIndex: number, trimRight: boolean): LineCacheEntry {
const strings = [];
const lineOffsets = [0];
let line = this._terminal.buffer.active.getLine(lineIndex);
while (line) {
const nextLine = this._terminal.buffer.active.getLine(lineIndex + 1);
const lineWrapsToNext = nextLine ? nextLine.isWrapped : false;
let string = line.translateToString(!lineWrapsToNext && trimRight);
if (lineWrapsToNext && nextLine) {
const lastCell = line.getCell(line.length - 1);
const lastCellIsNull = lastCell && lastCell.getCode() === 0 && lastCell.getWidth() === 1;
// a wide character wrapped to the next line
if (lastCellIsNull && nextLine.getCell(0)?.getWidth() === 2) {
string = string.slice(0, -1);
}
}
strings.push(string);
if (lineWrapsToNext) {
lineOffsets.push(lineOffsets[lineOffsets.length - 1] + string.length);
} else {
break;
}
lineIndex++;
line = nextLine;
}
return [strings.join(''), lineOffsets];
}
}
@@ -0,0 +1,119 @@
/**
* Copyright (c) 2017 The xterm.js authors. All rights reserved.
* @license MIT
*/
import type { ISearchResultChangeEvent } from '@xterm/addon-search';
import type { IDisposable } from '@xterm/xterm';
import { Emitter, type IEvent } from 'common/Event';
import { Disposable } from 'common/Lifecycle';
import type { ISearchResult } from './SearchEngine';
/**
* Interface for managing a currently selected decoration.
*/
interface ISelectedDecoration extends IDisposable {
match: ISearchResult;
}
/**
* Tracks search results, manages result indexing, and fires events when results change.
* This class provides centralized management of search result state and notifications.
*/
export class SearchResultTracker extends Disposable {
private _searchResults: ISearchResult[] = [];
private _selectedDecoration: ISelectedDecoration | undefined;
private readonly _onDidChangeResults = this._register(new Emitter<ISearchResultChangeEvent>());
public get onDidChangeResults(): IEvent<ISearchResultChangeEvent> { return this._onDidChangeResults.event; }
/**
* Gets the current search results.
*/
public get searchResults(): ReadonlyArray<ISearchResult> {
return this._searchResults;
}
/**
* Gets the currently selected decoration.
*/
public get selectedDecoration(): ISelectedDecoration | undefined {
return this._selectedDecoration;
}
/**
* Sets the currently selected decoration.
*/
public set selectedDecoration(decoration: ISelectedDecoration | undefined) {
this._selectedDecoration = decoration;
}
/**
* Updates the search results with a new set of results.
* @param results The new search results.
* @param maxResults The maximum number of results to track.
*/
public updateResults(results: ISearchResult[], maxResults: number): void {
this._searchResults = results.slice(0, maxResults);
}
/**
* Clears all search results.
*/
public clearResults(): void {
this._searchResults = [];
}
/**
* Clears the selected decoration.
*/
public clearSelectedDecoration(): void {
if (this._selectedDecoration) {
this._selectedDecoration.dispose();
this._selectedDecoration = undefined;
}
}
/**
* Finds the index of a result in the current results array.
* @param result The result to find.
* @returns The index of the result, or -1 if not found.
*/
public findResultIndex(result: ISearchResult): number {
for (let i = 0; i < this._searchResults.length; i++) {
const match = this._searchResults[i];
if (match.row === result.row && match.col === result.col && match.size === result.size) {
return i;
}
}
return -1;
}
/**
* Fires a result change event with the current state.
* @param hasDecorations Whether decorations are enabled.
*/
public fireResultsChanged(hasDecorations: boolean): void {
if (!hasDecorations) {
return;
}
let resultIndex = -1;
if (this._selectedDecoration) {
resultIndex = this.findResultIndex(this._selectedDecoration.match);
}
this._onDidChangeResults.fire({
resultIndex,
resultCount: this._searchResults.length
});
}
/**
* Resets all state.
*/
public reset(): void {
this.clearSelectedDecoration();
this.clearResults();
}
}
+106
View File
@@ -0,0 +1,106 @@
/**
* Copyright (c) 2017 The xterm.js authors. All rights reserved.
* @license MIT
*/
import type { ISearchOptions } from '@xterm/addon-search';
/**
* Manages search state including cached search terms, options tracking, and validation.
* This class provides a centralized way to handle search state consistency and option changes.
*/
export class SearchState {
private _cachedSearchTerm: string | undefined;
private _lastSearchOptions: ISearchOptions | undefined;
/**
* Gets the currently cached search term.
*/
public get cachedSearchTerm(): string | undefined {
return this._cachedSearchTerm;
}
/**
* Sets the cached search term.
*/
public set cachedSearchTerm(term: string | undefined) {
this._cachedSearchTerm = term;
}
/**
* Gets the last search options used.
*/
public get lastSearchOptions(): ISearchOptions | undefined {
return this._lastSearchOptions;
}
/**
* Sets the last search options used.
*/
public set lastSearchOptions(options: ISearchOptions | undefined) {
this._lastSearchOptions = options;
}
/**
* Validates a search term to ensure it's not empty or invalid.
* @param term The search term to validate.
* @returns true if the term is valid for searching.
*/
public isValidSearchTerm(term: string): boolean {
return !!(term && term.length > 0);
}
/**
* Determines if search options have changed compared to the last search.
* @param newOptions The new search options to compare.
* @returns true if the options have changed.
*/
public didOptionsChange(newOptions?: ISearchOptions): boolean {
if (!this._lastSearchOptions) {
return true;
}
if (!newOptions) {
return false;
}
if (this._lastSearchOptions.caseSensitive !== newOptions.caseSensitive) {
return true;
}
if (this._lastSearchOptions.regex !== newOptions.regex) {
return true;
}
if (this._lastSearchOptions.wholeWord !== newOptions.wholeWord) {
return true;
}
return false;
}
/**
* Determines if a new search should trigger highlighting updates.
* @param term The search term.
* @param options The search options.
* @returns true if highlighting should be updated.
*/
public shouldUpdateHighlighting(term: string, options?: ISearchOptions): boolean {
if (!options?.decorations) {
return false;
}
return this._cachedSearchTerm === undefined ||
term !== this._cachedSearchTerm ||
this.didOptionsChange(options);
}
/**
* Clears the cached search term.
*/
public clearCachedTerm(): void {
this._cachedSearchTerm = undefined;
}
/**
* Resets all state.
*/
public reset(): void {
this._cachedSearchTerm = undefined;
this._lastSearchOptions = undefined;
}
}
+2 -40
View File
@@ -45,45 +45,6 @@ test.describe('Search Tests', () => {
deepStrictEqual(await ctx.page.evaluate(`window.search.findNext('$^1_3{}test$#')`), true);
deepStrictEqual(await ctx.proxy.getSelection(), '$^1_3{}test$#');
});
test('Search should not scroll when match is already in viewport', async () => {
let dataString = '';
for (let i = 0; i < 100; i++) {
if (i === 40) {
dataString += `line ${i} needle-in-view`;
} else {
dataString += `line ${i}`;
}
dataString += '\n\r';
}
await ctx.proxy.write(dataString);
await ctx.page.evaluate('window.term.scrollToLine(35)');
strictEqual(await ctx.page.evaluate('window.term.buffer.active.viewportY'), 35);
strictEqual(await ctx.page.evaluate(`window.search.findNext('needle-in-view')`), true);
deepStrictEqual(await ctx.proxy.getSelection(), 'needle-in-view');
strictEqual(await ctx.page.evaluate('window.term.buffer.active.viewportY'), 35);
});
test('Search should center the match when it is outside the viewport', async () => {
let dataString = '';
for (let i = 0; i < 100; i++) {
if (i === 60) {
dataString += `line ${i} needle-center`;
} else {
dataString += `line ${i}`;
}
dataString += '\n\r';
}
await ctx.proxy.write(dataString);
await ctx.page.evaluate('window.term.scrollToLine(0)');
strictEqual(await ctx.page.evaluate('window.term.buffer.active.viewportY'), 0);
strictEqual(await ctx.page.evaluate(`window.search.findNext('needle-center')`), true);
deepStrictEqual(await ctx.proxy.getSelection(), 'needle-center');
deepStrictEqual(await ctx.page.evaluate('(() => { const viewportY = window.term.buffer.active.viewportY; const rows = window.term.rows; const selection = window.term.getSelectionPosition(); return { viewportY: viewportY, expectedViewportY: selection.start.y - Math.floor(rows / 2) }; })()'), {
viewportY: 48,
expectedViewportY: 48
});
});
test('Incremental Find Previous', async () => {
await ctx.proxy.writeln(`package.jsonc\n`);
await ctx.proxy.write('package.json pack package.lock');
@@ -316,7 +277,8 @@ test.describe('Search Tests', () => {
{ resultCount: 1, resultIndex: 0 },
{ resultCount: 2, resultIndex: 1 }
]);
strictEqual(await ctx.page.evaluate(`window.search.findPrevious('d', { decorations: { activeMatchColorOverviewRuler: '#ff0000' } })`), false);
await timeout(2000);
strictEqual(await ctx.page.evaluate(`debugger; window.search.findPrevious('d', { decorations: { activeMatchColorOverviewRuler: '#ff0000' } })`), false);
deepStrictEqual(await ctx.page.evaluate('window.calls'), [
{ resultCount: 1, resultIndex: 0 },
{ resultCount: 2, resultIndex: 1 },
+1 -2
View File
@@ -3,7 +3,6 @@
"include": [],
"references": [
{ "path": "./src" },
{ "path": "./test" },
{ "path": "./benchmark" }
{ "path": "./test" }
]
}
+12 -14
View File
@@ -17,8 +17,7 @@ declare module '@xterm/addon-search' {
/**
* Whether to search for a whole word, the result is only valid if it's
* surrounded by start/end of line or non-word separators such as whitespace
* and punctuation (for example `(`, `)`, or space).
* surrounded in "non-word" characters such as `_`, `(`, `)` or space.
*/
wholeWord?: boolean;
@@ -29,7 +28,8 @@ declare module '@xterm/addon-search' {
/**
* Whether to do an incremental search, this will expand the selection if it
* still matches the term the user typed.
* still matches the term the user typed. Note that this only affects
* `findNext`, not `findPrevious`.
*/
incremental?: boolean;
@@ -43,7 +43,7 @@ declare module '@xterm/addon-search' {
/**
* Options for showing decorations when searching.
*/
export interface ISearchDecorationOptions {
interface ISearchDecorationOptions {
/**
* The background color of a match, this must use #RRGGBB format.
*/
@@ -57,7 +57,7 @@ declare module '@xterm/addon-search' {
/**
* The overview ruler color of a match.
*/
matchOverviewRuler?: string;
matchOverviewRuler: string;
/**
* The background color for the currently active match, this must use #RRGGBB format.
@@ -72,7 +72,7 @@ declare module '@xterm/addon-search' {
/**
* The overview ruler color of the currently active match.
*/
activeMatchColorOverviewRuler?: string;
activeMatchColorOverviewRuler: string;
}
/**
@@ -80,13 +80,12 @@ declare module '@xterm/addon-search' {
*/
export interface ISearchResultChangeEvent {
/**
* The index of the currently active result within tracked highlights, or -1 when
* the active match is not in the tracked result set.
* The index of the currently active result, -1 when the threshold of matches is exceeded.
*/
resultIndex: number;
/**
* The number of tracked search results (capped by `highlightLimit` when decorations are enabled).
* The total number of search results found.
*/
resultCount: number;
}
@@ -97,9 +96,9 @@ declare module '@xterm/addon-search' {
export interface ISearchAddonOptions {
/**
* Max number of matches highlighted when decorations are enabled.
* Defaults to 1000. Invalid values fall back to the default.
* Defaults to 1000 highlighted matches
*/
highlightLimit?: number;
highlightLimit: number
}
/**
@@ -141,7 +140,7 @@ declare module '@xterm/addon-search' {
public findPrevious(term: string, searchOptions?: ISearchOptions): boolean;
/**
* Clears highlight decorations and tracked results.
* Clears the decorations and selection
*/
public clearDecorations(): void;
@@ -163,8 +162,7 @@ declare module '@xterm/addon-search' {
readonly onBeforeSearch: IEvent<void>;
/**
* When decorations are enabled, fires after `findNext`/`findPrevious` when tracked
* search results change. Does not fire from `clearDecorations()`.
* When decorations are enabled, fires when the search results change.
*/
readonly onDidChangeResults: IEvent<ISearchResultChangeEvent>;
}
+1 -7
View File
@@ -358,9 +358,7 @@ export class GlyphRenderer extends Disposable {
gl.bindBuffer(gl.ARRAY_BUFFER, this._attributesBuffer);
gl.bufferData(gl.ARRAY_BUFFER, activeBuffer.subarray(0, bufferLength), gl.STREAM_DRAW);
// Bind the atlas page texture if they have changed. AtlasPage.version is globally
// monotonic, so a page object swap at the same index (which happens after a page merge)
// is detected by the same comparison.
// Bind the atlas page texture if they have changed
for (let i = 0; i < this._atlas.pages.length; i++) {
if (this._atlas.pages[i].version !== this._atlasTextures[i].version) {
this._bindAtlasPageTexture(gl, this._atlas, i);
@@ -373,10 +371,6 @@ export class GlyphRenderer extends Disposable {
public setAtlas(atlas: ITextureAtlas): void {
this._atlas = atlas;
this.invalidateAtlasTextures();
}
public invalidateAtlasTextures(): void {
for (const glTexture of this._atlasTextures) {
glTexture.version = -1;
}
+2 -3
View File
@@ -336,9 +336,8 @@ export class RectangleRenderer extends Disposable {
}
}
if (vertices.attributes.length < offset + INDICES_PER_RECTANGLE) {
// +1 for the viewport-clear rectangle at offset 0.
vertices.attributes = expandFloat32Array(vertices.attributes, (this._terminal.rows * this._terminal.cols + 1) * INDICES_PER_RECTANGLE);
if (vertices.attributes.length < offset + 4) {
vertices.attributes = expandFloat32Array(vertices.attributes, this._terminal.rows * this._terminal.cols * INDICES_PER_RECTANGLE);
}
$x1 = startX * this._dimensions.device.cell.width;
$y1 = y * this._dimensions.device.cell.height;
+7 -13
View File
@@ -133,9 +133,7 @@ export class TextureAtlas implements ITextureAtlas {
private _requestClearModel = false;
public beginFrame(): boolean {
const result = this._requestClearModel;
this._requestClearModel = false;
return result;
return this._requestClearModel;
}
public clearTexture(): void {
@@ -195,7 +193,7 @@ export class TextureAtlas implements ITextureAtlas {
// Merge into the new page
const mergedPage = this._mergePages(mergingPages, mergedPageIndex);
mergedPage.version = ++AtlasPage.nextVersion;
mergedPage.version++;
// Delete the pages, shifting glyph texture pages as needed
for (let i = sortedMergingPagesIndexes.length - 1; i >= 0; i--) {
@@ -253,7 +251,7 @@ export class TextureAtlas implements ITextureAtlas {
for (const g of adjustingPage.glyphs) {
g.texturePage--;
}
adjustingPage.version = ++AtlasPage.nextVersion;
adjustingPage.version++;
}
}
@@ -939,7 +937,7 @@ export class TextureAtlas implements ITextureAtlas {
rasterizedGlyph.size.y
);
activePage.addGlyph(rasterizedGlyph);
activePage.version = ++AtlasPage.nextVersion;
activePage.version++;
return rasterizedGlyph;
}
@@ -1049,13 +1047,9 @@ class AtlasPage {
}
/**
* Monotonically increasing across all atlas pages globally. Used to detect when the texture
* unit at a given index needs to be re-uploaded — both for content changes within the same
* page and for a page object swap at the same index (which happens after a page merge,
* where a per-page counter could coincide with the previously-bound page's value).
* Used to check whether the canvas of the atlas page has changed.
*/
public static nextVersion: number = 0;
public version = ++AtlasPage.nextVersion;
public version = 0;
// Texture atlas current positioning data. The texture packing strategy used is to fill from
// left-to-right and top-to-bottom. When the glyph being written is less than half of the current
@@ -1098,7 +1092,7 @@ class AtlasPage {
this.currentRow.y = 0;
this.currentRow.height = 0;
this.fixedRows.length = 0;
this.version = ++AtlasPage.nextVersion;
this.version++;
}
}
-17
View File
@@ -28,10 +28,6 @@ import { addDisposableListener } from 'browser/Dom';
import { combinedDisposable, Disposable, MutableDisposable, toDisposable } from 'common/Lifecycle';
import { createRenderDimensions } from 'browser/renderer/shared/RendererUtils';
const enum Constants {
MERGE_RETRY_LIMIT = 32
}
export class WebglRenderer extends Disposable implements IRenderer {
private _renderLayers: IRenderLayer[];
private _cursorBlinkStateManager: MutableDisposable<CursorBlinkStateManager> = this._register(new MutableDisposable());
@@ -379,19 +375,6 @@ export class WebglRenderer extends Disposable implements IRenderer {
this._updateModel(start, end);
}
// A mid-update atlas page merge invalidates vertex data and may not bump the host
// page's version, so re-run the update and force a full texture rebind.
let merged = false;
let mergeRetries = 0;
while (this._charAtlas && this._glyphRenderer.value.beginFrame() && mergeRetries++ < Constants.MERGE_RETRY_LIMIT) {
merged = true;
this._clearModel(true);
this._updateModel(0, this._terminal.rows - 1);
}
if (merged) {
this._glyphRenderer.value.invalidateAtlasTextures();
}
// Render
this._rectangleRenderer.value.renderBackgrounds();
this._glyphRenderer.value.render(this._model);
+196
View File
@@ -0,0 +1,196 @@
from termios import tcgetattr, tcsetattr, TCSADRAIN, TIOCGWINSZ, TCSAFLUSH
from tty import setcbreak, setraw
import os
import sys
from contextlib import contextmanager
from select import select
from time import sleep
from json import dumps, loads
from typing import Optional, Tuple, Union
class TerminalContext:
def __init__(self, fd: int, close_fd=False) -> None:
if not os.isatty(fd):
raise TypeError('fd is not a terminal')
self.close_fd = close_fd
self.fd = fd
self.is_cbreak = False
self.is_raw = False
self._initial_attr = self.termios_attributes
@classmethod
def from_cterm(cls):
fd = os.open(os.ctermid(), os.O_RDWR)
return TerminalContext(fd, True)
def close(self) -> None:
tcsetattr(self.fd, TCSADRAIN, self._initial_attr)
if self.close_fd:
os.close(self.fd)
@property
def termios_attributes(self) -> list:
return tcgetattr(self.fd)
@property
def ttyname(self) -> str:
return os.ttyname(self.fd)
@contextmanager
def cbreak_mode(self):
"""
Enter cbreak mode context.
"""
if self.is_cbreak:
yield
return
tattr = self.termios_attributes
try:
setcbreak(self.fd, TCSADRAIN)
self.is_cbreak = True
yield
finally:
tcsetattr(self.fd, TCSADRAIN, tattr)
self.is_cbreak = False
@contextmanager
def raw_mode(self):
"""
Enter cbreak mode context.
"""
if self.is_raw:
yield
return
tattr = self.termios_attributes
try:
setraw(self.fd, TCSADRAIN)
self.is_raw = True
yield
finally:
tcsetattr(self.fd, TCSADRAIN, tattr)
self.is_raw = False
@contextmanager
def custom_state(self, undo=None):
"""
Enter custom terminal state, that needs to to be undone by ``undo``.
Useful, if you want to apply a custom terminal state and
have to make sure, that it gets properly reset to previous state.
"""
try:
yield
finally:
if undo:
undo()
def write(self, data: Union[str, bytes]) -> None:
"""
Write string or bytes directly to the terminal.
"""
if isinstance(data, str):
data = data.encode('utf-8')
sent = os.write(self.fd, data)
while sent:
data = data[sent:]
sent = os.write(self.fd, data)
def read(self, amount: int = 1024, timeout: Optional[float] = None) -> bytes:
"""
Blocking read from the terminal.
If nothing was sent from the terminal within ``timeout``,
empty bytes are returned.
"""
can_read, _, _ = select([self.fd], [], [], timeout)
if can_read:
return os.read(self.fd, amount)
return b''
@contextmanager
def cterminal_context():
t = TerminalContext.from_cterm()
try:
yield t
finally:
t.close()
def extract_events(data: list[str]):
if len(data) < 5:
raise Exception('not enough reports')
types = set(data)
if len(types) == 1:
return {'PRESS': data[0], 'REPEAT': data[0], 'RELEASE': None}
if len(types) == 2:
last = data.pop()
if last != data[0] and len(set(data)) == 1:
return {'PRESS': data[0], 'REPEAT': data[0], 'RELEASE': last}
raise Exception('weird reports, 2 types')
if len(types) == 3:
first = data.pop(0)
last = data.pop()
if first != last and first != data[0] and last != data[0]:
return {'PRESS': first, 'REPEAT': data[0], 'RELEASE': last}
raise Exception('weird reports, 3 types')
raise Exception('more than 3 types')
def query(term: TerminalContext, mode: int):
with term.custom_state(undo=lambda:term.write('\x1b[<u')):
term.write(f'\x1b[>{mode}u')
term.read(timeout=.1)
print('PRESS (within 5s) and HOLD (for 5s)\r')
data: list[bytes] = []
cur = term.read(timeout=5)
while cur:
data.append(cur)
cur = term.read(timeout=.5)
if len(data) > 5:
print('RELEASE\r', end='')
try:
return extract_events([b.decode('utf-8') for b in data])
except Exception as e:
print('Error:', e, '\r')
print(data, '\r')
term.read(timeout=.1)
def save(filedata: dict[str, dict[str, str]], filename, entry, mode, events):
try:
filedata[entry][mode] = events
except KeyError:
filedata[entry] = {mode: events}
with open(filename, 'w') as f:
f.write(dumps(filedata, indent=2))
def main():
mode = 1
filedata = {}
print(sys.argv)
if len(sys.argv) != 3:
print('ERROR: not enough arguments')
print('Usage: python kitty_keytester.py <mode> <json-file>')
return
mode = int(sys.argv[1])
filename = sys.argv[2]
if os.path.exists(filename):
with open(filename) as f:
filedata = loads(f.read())
with cterminal_context() as term:
while True:
with term.raw_mode():
events = query(term, mode)
if events:
print(events)
entry = input('Entry: ')
if entry:
save(filedata, filename, entry, str(mode), events)
cont = input('Continue? (y)')
if cont not in ['y', '']:
break
if __name__ == '__main__':
main()
@@ -252,11 +252,6 @@ export class OptionsWindow extends BaseWindow implements IControlWindow {
input.value = 'auto';
this._handlers.updateTerminalSize();
}
const screenElement = document.querySelector('.xterm-screen');
if (screenElement instanceof HTMLElement) {
screenElement.style.outline = this._autoResize ? ''
: 'solid thin red';
}
} else if (o === 'theme') {
value = this._getTheme();
}
@@ -0,0 +1,373 @@
{
"Escape +CapsLock": {
"1": {
"PRESS": "\u001b[27;65u",
"REPEAT": "\u001b[27;65u",
"RELEASE": null
}
},
"F1 +CapsLock": {
"1": {
"PRESS": "\u001b[1;65P",
"REPEAT": "\u001b[1;65P",
"RELEASE": null
}
},
"Insert +CapsLock": {
"1": {
"PRESS": "\u001b[2;65~",
"REPEAT": "\u001b[2;65~",
"RELEASE": null
}
},
"Delete +CapsLock": {
"1": {
"PRESS": "\u001b[3;65~",
"REPEAT": "\u001b[3;65~",
"RELEASE": null
}
},
"PageUp +CapsLock": {
"1": {
"PRESS": "\u001b[5;65~",
"REPEAT": "\u001b[5;65~",
"RELEASE": null
}
},
"Dead (Backquote) +CapsLock": {
"1": {
"PRESS": "^",
"REPEAT": "^",
"RELEASE": null
}
},
"1 (Digit1) +CapsLock": {
"1": {
"PRESS": "1",
"REPEAT": "1",
"RELEASE": null
}
},
"\u00df (Minus) +CapsLock": {
"1": {
"PRESS": "\u1e9e",
"REPEAT": "\u1e9e",
"RELEASE": null
}
},
"Dead (Equal) +CapsLock": {
"1": {
"PRESS": "\u00b4",
"REPEAT": "\u00b4",
"RELEASE": null
}
},
"Backspace +CapsLock": {
"1": {
"PRESS": "\u007f",
"REPEAT": "\u007f",
"RELEASE": null
}
},
"Tab +CapsLock": {
"1": {
"PRESS": "\t",
"REPEAT": "\t",
"RELEASE": null
}
},
"Q (KeyQ) +CapsLock": {
"1": {
"PRESS": "Q",
"REPEAT": "Q",
"RELEASE": null
}
},
"\u00dc (BracketLeft) +CapsLock": {
"1": {
"PRESS": "\u00dc",
"REPEAT": "\u00dc",
"RELEASE": null
}
},
"+ (BracketRight) +CapsLock": {
"1": {
"PRESS": "+",
"REPEAT": "+",
"RELEASE": null
}
},
"Enter +CapsLock": {
"1": {
"PRESS": "\r",
"REPEAT": "\r",
"RELEASE": null
}
},
"\u00d6 (Semicolon) +CapsLock": {
"1": {
"PRESS": "\u00d6",
"REPEAT": "\u00d6",
"RELEASE": null
}
},
"\u00c4 (Quote) +CapsLock": {
"1": {
"PRESS": "\u00c4",
"REPEAT": "\u00c4",
"RELEASE": null
}
},
"# (Bashslash) +CapsLock": {
"1": {
"PRESS": "#",
"REPEAT": "#",
"RELEASE": null
}
},
"< (IntlBackslash) +CapsLock": {
"1": {
"PRESS": "<",
"REPEAT": "<",
"RELEASE": null
}
},
", (Comma) +CapsLock": {
"1": {
"PRESS": ",",
"REPEAT": ",",
"RELEASE": null
}
},
". (Period) +CapsLock": {
"1": {
"PRESS": ".",
"REPEAT": ".",
"RELEASE": null
}
},
"- (Slash) +CapsLock": {
"1": {
"PRESS": "-",
"REPEAT": "-",
"RELEASE": null
}
},
"ArrowUp +CapsLock": {
"1": {
"PRESS": "\u001b[1;65A",
"REPEAT": "\u001b[1;65A",
"RELEASE": null
}
},
"PageDown +CapsLock": {
"1": {
"PRESS": "\u001b[6;65~",
"REPEAT": "\u001b[6;65~",
"RELEASE": null
}
},
"Home +CapsLock": {
"1": {
"PRESS": "\u001b[1;65H",
"REPEAT": "\u001b[1;65H",
"RELEASE": null
}
},
"End +CapsLock": {
"1": {
"PRESS": "\u001b[1;65F",
"REPEAT": "\u001b[1;65F",
"RELEASE": null
}
},
"+ (NumpadAdd) +CapsLock": {
"1": {
"PRESS": "+",
"REPEAT": "+",
"RELEASE": null
}
},
"Enter (NumpadEnter) +CapsLock": {
"1": {
"PRESS": "\u001b[57414;65u",
"REPEAT": "\u001b[57414;65u",
"RELEASE": null
}
},
"Insert (Numpad0) +CapsLock": {
"1": {
"PRESS": "\u001b[57425;65u",
"REPEAT": "\u001b[57425;65u",
"RELEASE": null
}
},
"\u0000 (NumpadDecimal) +CapsLock": {
"1": {
"PRESS": "\u001b[57426;65u",
"REPEAT": "\u001b[57426;65u",
"RELEASE": null
}
},
"Numlock +CapsLock": {
"1": {
"PRESS": null,
"REPEAT": null,
"RELEASE": null
}
},
"CapsLock +CapsLock": {
"1": {
"PRESS": null,
"REPEAT": null,
"RELEASE": null
}
},
"Shift (ShiftLeft) +CapsLock": {
"1": {
"PRESS": null,
"REPEAT": null,
"RELEASE": null
}
},
"Shift (ShiftRight) +CapsLock": {
"1": {
"PRESS": null,
"REPEAT": null,
"RELEASE": null
}
},
"Control (ControlLeft) +CapsLock": {
"1": {
"PRESS": null,
"REPEAT": null,
"RELEASE": null
}
},
"Meta (MetaLeft) +CapsLock": {
"1": {
"PRESS": null,
"REPEAT": null,
"RELEASE": null
}
},
"Alt (AltLeft) +CapsLock": {
"1": {
"PRESS": null,
"REPEAT": null,
"RELEASE": null
}
},
"AltGraph (AltRight) +CapsLock": {
"1": {
"PRESS": null,
"REPEAT": null,
"RELEASE": null
}
},
"Control (ControlRight) +CapsLock": {
"1": {
"PRESS": null,
"REPEAT": null,
"RELEASE": null
}
},
"/ (NumpadDivide) +NumLock": {
"1": {
"PRESS": "/",
"REPEAT": "/",
"RELEASE": null
}
},
"* (NumpadMultiply) +NumLock": {
"1": {
"PRESS": "*",
"REPEAT": "*",
"RELEASE": null
}
},
"- (NumpadSubtract) +NumLock": {
"1": {
"PRESS": "-",
"REPEAT": "-",
"RELEASE": null
}
},
"0 (Numpad0) +NumLock": {
"1": {
"PRESS": "0",
"REPEAT": "0",
"RELEASE": null
}
},
"1 (Numpad1) +NumLock": {
"1": {
"PRESS": "1",
"REPEAT": "1",
"RELEASE": null
}
},
"2 (Numpad2) +NumLock": {
"1": {
"PRESS": "2",
"REPEAT": "2",
"RELEASE": null
}
},
"3 (Numpad3) +NumLock": {
"1": {
"PRESS": "3",
"REPEAT": "3",
"RELEASE": null
}
},
"4 (Numpad4) +NumLock": {
"1": {
"PRESS": "4",
"REPEAT": "4",
"RELEASE": null
}
},
"5 (Numpad5) +NumLock": {
"1": {
"PRESS": "5",
"REPEAT": "5",
"RELEASE": null
}
},
"6 (Numpad6) +NumLock": {
"1": {
"PRESS": "6",
"REPEAT": "6",
"RELEASE": null
}
},
"7 (Numpad7) +NumLock": {
"1": {
"PRESS": "7",
"REPEAT": "7",
"RELEASE": null
}
},
"8 (Numpad8) +NumLock": {
"1": {
"PRESS": "8",
"REPEAT": "8",
"RELEASE": null
}
},
"9 (Numpad9) +NumLock": {
"1": {
"PRESS": "9",
"REPEAT": "9",
"RELEASE": null
}
},
", (NumpadDecimal) +NumLock": {
"1": {
"PRESS": ",",
"REPEAT": ",",
"RELEASE": null
}
}
}
@@ -0,0 +1,702 @@
{
"Escape": {
"1": {
"PRESS": "\u001b[27u",
"REPEAT": "\u001b[27u",
"RELEASE": null
}
},
"F1": {
"1": {
"PRESS": "\u001b[P",
"REPEAT": "\u001b[P",
"RELEASE": null
}
},
"F2": {
"1": {
"PRESS": "\u001b[Q",
"REPEAT": "\u001b[Q",
"RELEASE": null
}
},
"F3": {
"1": {
"PRESS": "\u001b[13~",
"REPEAT": "\u001b[13~",
"RELEASE": null
}
},
"F4": {
"1": {
"PRESS": "\u001b[S",
"REPEAT": "\u001b[S",
"RELEASE": null
}
},
"F5": {
"1": {
"PRESS": "\u001b[15~",
"REPEAT": "\u001b[15~",
"RELEASE": null
}
},
"F6": {
"1": {
"PRESS": "\u001b[17~",
"REPEAT": "\u001b[17~",
"RELEASE": null
}
},
"F7": {
"1": {
"PRESS": "\u001b[18~",
"REPEAT": "\u001b[18~",
"RELEASE": null
}
},
"F8": {
"1": {
"PRESS": "\u001b[19~",
"REPEAT": "\u001b[19~",
"RELEASE": null
}
},
"F9": {
"1": {
"PRESS": "\u001b[20~",
"REPEAT": "\u001b[20~",
"RELEASE": null
}
},
"F10": {
"1": {
"PRESS": "\u001b[21~",
"REPEAT": "\u001b[21~",
"RELEASE": null
}
},
"F11": {
"1": {
"PRESS": "\u001b[23~",
"REPEAT": "\u001b[23~",
"RELEASE": null
}
},
"F12": {
"1": {
"PRESS": "\u001b[24~",
"REPEAT": "\u001b[24~",
"RELEASE": null
}
},
"Insert": {
"1": {
"PRESS": "\u001b[2~",
"REPEAT": "\u001b[2~",
"RELEASE": null
}
},
"Delete": {
"1": {
"PRESS": "\u001b[3~",
"REPEAT": "\u001b[3~",
"RELEASE": null
}
},
"PageUp": {
"1": {
"PRESS": "\u001b[5~",
"REPEAT": "\u001b[5~",
"RELEASE": null
}
},
"PageDown": {
"1": {
"PRESS": "\u001b[6~",
"REPEAT": "\u001b[6~",
"RELEASE": null
}
},
"Home": {
"1": {
"PRESS": "\u001b[H",
"REPEAT": "\u001b[H",
"RELEASE": null
}
},
"End": {
"1": {
"PRESS": "\u001b[F",
"REPEAT": "\u001b[F",
"RELEASE": null
}
},
"Dead (Backquote)": {
"1": {
"PRESS": "^",
"REPEAT": "^",
"RELEASE": null
}
},
"1 (Digit1)": {
"1": {
"PRESS": "1",
"REPEAT": "1",
"RELEASE": null
}
},
"2 (Digit2)": {
"1": {
"PRESS": "2",
"REPEAT": "2",
"RELEASE": null
}
},
"3 (Digit3)": {
"1": {
"PRESS": "3",
"REPEAT": "3",
"RELEASE": null
}
},
"4 (Digit4)": {
"1": {
"PRESS": "4",
"REPEAT": "4",
"RELEASE": null
}
},
"5 (Digit5)": {
"1": {
"PRESS": "5",
"REPEAT": "5",
"RELEASE": null
}
},
"6 (Digit6)": {
"1": {
"PRESS": "6",
"REPEAT": "6",
"RELEASE": null
}
},
"7 (Digit7)": {
"1": {
"PRESS": "7",
"REPEAT": "7",
"RELEASE": null
}
},
"8 (Digit8)": {
"1": {
"PRESS": "8",
"REPEAT": "8",
"RELEASE": null
}
},
"9 (Digit9)": {
"1": {
"PRESS": "9",
"REPEAT": "9",
"RELEASE": null
}
},
"0 (Digit0)": {
"1": {
"PRESS": "0",
"REPEAT": "0",
"RELEASE": null
}
},
"\u00df (Minus)": {
"1": {
"PRESS": "\u00df",
"REPEAT": "\u00df",
"RELEASE": null
}
},
"Dead (Equal)": {
"1": {
"PRESS": "\u00b4",
"REPEAT": "\u00b4",
"RELEASE": null
}
},
"Backspace": {
"1": {
"PRESS": "\u007f",
"REPEAT": "\u007f",
"RELEASE": null
}
},
"Tab": {
"1": {
"PRESS": "\t",
"REPEAT": "\t",
"RELEASE": null
}
},
"q (KeyQ)": {
"1": {
"PRESS": "q",
"REPEAT": "q",
"RELEASE": null
}
},
"w (KeyW)": {
"1": {
"PRESS": "w",
"REPEAT": "w",
"RELEASE": null
}
},
"e (KeyE)": {
"1": {
"PRESS": "e",
"REPEAT": "e",
"RELEASE": null
}
},
"r (KeyR)": {
"1": {
"PRESS": "r",
"REPEAT": "r",
"RELEASE": null
}
},
"t (KeyT)": {
"1": {
"PRESS": "t",
"REPEAT": "t",
"RELEASE": null
}
},
"z (KeyY)": {
"1": {
"PRESS": "z",
"REPEAT": "z",
"RELEASE": null
}
},
"u (KeyU)": {
"1": {
"PRESS": "u",
"REPEAT": "u",
"RELEASE": null
}
},
"i (KeyI)": {
"1": {
"PRESS": "i",
"REPEAT": "i",
"RELEASE": null
}
},
"o (KeyO)": {
"1": {
"PRESS": "o",
"REPEAT": "o",
"RELEASE": null
}
},
"p (KeyP)": {
"1": {
"PRESS": "p",
"REPEAT": "p",
"RELEASE": null
}
},
"\u00fc (BracketLeft)": {
"1": {
"PRESS": "\u00fc",
"REPEAT": "\u00fc",
"RELEASE": null
}
},
"+ (BracketRight)": {
"1": {
"PRESS": "+",
"REPEAT": "+",
"RELEASE": null
}
},
"Enter": {
"1": {
"PRESS": "\r",
"REPEAT": "\r",
"RELEASE": null
}
},
"a (KeyA)": {
"1": {
"PRESS": "a",
"REPEAT": "a",
"RELEASE": null
}
},
"s (KeyS)": {
"1": {
"PRESS": "s",
"REPEAT": "s",
"RELEASE": null
}
},
"d (KeyD)": {
"1": {
"PRESS": "d",
"REPEAT": "d",
"RELEASE": null
}
},
"f (KeyF)": {
"1": {
"PRESS": "f",
"REPEAT": "f",
"RELEASE": null
}
},
"g (KeyG)": {
"1": {
"PRESS": "g",
"REPEAT": "g",
"RELEASE": null
}
},
"h (KeyH)": {
"1": {
"PRESS": "h",
"REPEAT": "h",
"RELEASE": null
}
},
"j (KeyJ)": {
"1": {
"PRESS": "j",
"REPEAT": "j",
"RELEASE": null
}
},
"k (KeyK)": {
"1": {
"PRESS": "k",
"REPEAT": "k",
"RELEASE": null
}
},
"l (KeyL)": {
"1": {
"PRESS": "l",
"REPEAT": "l",
"RELEASE": null
}
},
"\u00f6 (Semicolon)": {
"1": {
"PRESS": "\u00f6",
"REPEAT": "\u00f6",
"RELEASE": null
}
},
"\u00e4 (Quote)": {
"1": {
"PRESS": "\u00e4",
"REPEAT": "\u00e4",
"RELEASE": null
}
},
"# (Backslash)": {
"1": {
"PRESS": "#",
"REPEAT": "#",
"RELEASE": null
}
},
"< (IntlBackslash)": {
"1": {
"PRESS": "<",
"REPEAT": "<",
"RELEASE": null
}
},
"y (KeyZ)": {
"1": {
"PRESS": "y",
"REPEAT": "y",
"RELEASE": null
}
},
"x (KeyX)": {
"1": {
"PRESS": "x",
"REPEAT": "x",
"RELEASE": null
}
},
"c (KeyC)": {
"1": {
"PRESS": "c",
"REPEAT": "c",
"RELEASE": null
}
},
"v (KeyV)": {
"1": {
"PRESS": "v",
"REPEAT": "v",
"RELEASE": null
}
},
"b (KeyB)": {
"1": {
"PRESS": "b",
"REPEAT": "b",
"RELEASE": null
}
},
"n (KeyN)": {
"1": {
"PRESS": "n",
"REPEAT": "n",
"RELEASE": null
}
},
"m (KeyM)": {
"1": {
"PRESS": "m",
"REPEAT": "m",
"RELEASE": null
}
},
", (Comma)": {
"1": {
"PRESS": ",",
"REPEAT": ",",
"RELEASE": null
}
},
". (Period)": {
"1": {
"PRESS": ".",
"REPEAT": ".",
"RELEASE": null
}
},
"- (Slash)": {
"1": {
"PRESS": "-",
"REPEAT": "-",
"RELEASE": null
}
},
"ArrowLeft": {
"1": {
"PRESS": "\u001b[D",
"REPEAT": "\u001b[D",
"RELEASE": null
}
},
"ArrowUp": {
"1": {
"PRESS": "\u001b[A",
"REPEAT": "\u001b[A",
"RELEASE": null
}
},
"ArrowRight": {
"1": {
"PRESS": "\u001b[C",
"REPEAT": "\u001b[C",
"RELEASE": null
}
},
"ArrowDown": {
"1": {
"PRESS": "\u001b[B",
"REPEAT": "\u001b[B",
"RELEASE": null
}
},
"/ (NumpadDivide)": {
"1": {
"PRESS": "/",
"REPEAT": "/",
"RELEASE": null
}
},
"* (NumpadMultiply)": {
"1": {
"PRESS": "*",
"REPEAT": "*",
"RELEASE": null
}
},
"- (NumpadSubtract)": {
"1": {
"PRESS": "-",
"REPEAT": "-",
"RELEASE": null
}
},
"Home (Numpad7)": {
"1": {
"PRESS": "\u001b[57423u",
"REPEAT": "\u001b[57423u",
"RELEASE": null
}
},
"ArrowUp (Numpad8)": {
"1": {
"PRESS": "\u001b[57419u",
"REPEAT": "\u001b[57419u",
"RELEASE": null
}
},
"PageUp (Numpad9)": {
"1": {
"PRESS": "\u001b[57421u",
"REPEAT": "\u001b[57421u",
"RELEASE": null
}
},
"+ (NumpadAdd)": {
"1": {
"PRESS": "+",
"REPEAT": "+",
"RELEASE": null
}
},
"ArrowLeft (Numpad4)": {
"1": {
"PRESS": "\u001b[57417u",
"REPEAT": "\u001b[57417u",
"RELEASE": null
}
},
"Clear (Numpad5)": {
"1": {
"PRESS": "\u001b[E",
"REPEAT": "\u001b[E",
"RELEASE": null
}
},
"ArrowRight (Numpad6)": {
"1": {
"PRESS": "\u001b[57418u",
"REPEAT": "\u001b[57418u",
"RELEASE": null
}
},
"End (Numpad1)": {
"1": {
"PRESS": "\u001b[57424u",
"REPEAT": "\u001b[57424u",
"RELEASE": null
}
},
"ArrowDown (Numpad2)": {
"1": {
"PRESS": "\u001b[57420u",
"REPEAT": "\u001b[57420u",
"RELEASE": null
}
},
"PageDown (Numpad3)": {
"1": {
"PRESS": "\u001b[57422u",
"REPEAT": "\u001b[57422u",
"RELEASE": null
}
},
"Enter (NumpadEnter)": {
"1": {
"PRESS": "\u001b[57414u",
"REPEAT": "\u001b[57414u",
"RELEASE": null
}
},
"Insert (Numpad0)": {
"1": {
"PRESS": "\u001b[57425u",
"REPEAT": "\u001b[57425u",
"RELEASE": null
}
},
"\u0000 (NumpadDecimal)": {
"1": {
"PRESS": "\u001b[57426u",
"REPEAT": "\u001b[57426u",
"RELEASE": null
}
},
"Numlock": {
"1": {
"PRESS": null,
"REPEAT": null,
"RELEASE": null
}
},
"CapsLock": {
"1": {
"PRESS": null,
"REPEAT": null,
"RELEASE": null
}
},
"Shift (ShiftLeft)": {
"1": {
"PRESS": null,
"REPEAT": null,
"RELEASE": null
}
},
"Shift (ShiftRight)": {
"1": {
"PRESS": null,
"REPEAT": null,
"RELEASE": null
}
},
"Control (ControlLeft)": {
"1": {
"PRESS": null,
"REPEAT": null,
"RELEASE": null
}
},
"Meta (MetaLeft)": {
"1": {
"PRESS": null,
"REPEAT": null,
"RELEASE": null
}
},
"Alt (AltLeft)": {
"1": {
"PRESS": null,
"REPEAT": null,
"RELEASE": null
}
},
" (Space)": {
"1": {
"PRESS": " ",
"REPEAT": " ",
"RELEASE": null
}
},
"AltGraph (AltRight)": {
"1": {
"PRESS": null,
"REPEAT": null,
"RELEASE": null
}
},
"Control (ControlRight)": {
"1": {
"PRESS": null,
"REPEAT": null,
"RELEASE": null
}
}
}
@@ -0,0 +1,331 @@
{
"Escape +Shift": {
"1": {
"PRESS": "\u001b[27;2u",
"REPEAT": "\u001b[27;2u",
"RELEASE": null
}
},
"F1 +Shift": {
"1": {
"PRESS": "\u001b[1;2P",
"REPEAT": "\u001b[1;2P",
"RELEASE": null
}
},
"Delete +Shift": {
"1": {
"PRESS": "\u001b[3;2~",
"REPEAT": "\u001b[3;2~",
"RELEASE": null
}
},
"PageUp +Shift": {
"1": {
"PRESS": "\u001b[5;2~",
"REPEAT": "\u001b[5;2~",
"RELEASE": null
}
},
"Dead (Backquote) +Shift": {
"1": {
"PRESS": "\u00b0",
"REPEAT": "\u00b0",
"RELEASE": null
}
},
"! (Digit1) +Shift": {
"1": {
"PRESS": "!",
"REPEAT": "!",
"RELEASE": null
}
},
"\" (Digit2) +Shift": {
"1": {
"PRESS": "\"",
"REPEAT": "\"",
"RELEASE": null
}
},
"\u00a7 (Digit3) +Shift": {
"1": {
"PRESS": "\u00a7",
"REPEAT": "\u00a7",
"RELEASE": null
}
},
"$ (Digit4) +Shifft": {
"1": {
"PRESS": "$",
"REPEAT": "$",
"RELEASE": null
}
},
"% (Digit5) +Shift": {
"1": {
"PRESS": "%",
"REPEAT": "%",
"RELEASE": null
}
},
"& (Digit6) +Shift": {
"1": {
"PRESS": "&",
"REPEAT": "&",
"RELEASE": null
}
},
"/ (Digit7) +Shift": {
"1": {
"PRESS": "/",
"REPEAT": "/",
"RELEASE": null
}
},
"( (Digit8) +Shift": {
"1": {
"PRESS": "(",
"REPEAT": "(",
"RELEASE": null
}
},
") (Digit9) +Shift": {
"1": {
"PRESS": ")",
"REPEAT": ")",
"RELEASE": null
}
},
"= (Digit0) +Shift": {
"1": {
"PRESS": "=",
"REPEAT": "=",
"RELEASE": null
}
},
"? (Minus) +Shift": {
"1": {
"PRESS": "?",
"REPEAT": "?",
"RELEASE": null
}
},
"Backspace +Shift": {
"1": {
"PRESS": "\u001b[127;2u",
"REPEAT": "\u001b[127;2u",
"RELEASE": null
}
},
"Tab +Shift": {
"1": {
"PRESS": "\u001b[9;2u",
"REPEAT": "\u001b[9;2u",
"RELEASE": null
}
},
"Q (KeyQ) +Shift": {
"1": {
"PRESS": "Q",
"REPEAT": "Q",
"RELEASE": null
}
},
"\u00dc (BracketLeft) +Shift": {
"1": {
"PRESS": "\u00dc",
"REPEAT": "\u00dc",
"RELEASE": null
}
},
"* (BracketRight) +Shift": {
"1": {
"PRESS": "*",
"REPEAT": "*",
"RELEASE": null
}
},
"Enter +Shift": {
"1": {
"PRESS": "\u001b[13;2u",
"REPEAT": "\u001b[13;2u",
"RELEASE": null
}
},
"\u00d6 (Semicolon) +Shift": {
"1": {
"PRESS": "\u00d6",
"REPEAT": "\u00d6",
"RELEASE": null
}
},
"\u00c4 (Quote) +Shift": {
"1": {
"PRESS": "\u00c4",
"REPEAT": "\u00c4",
"RELEASE": null
}
},
"' (Backslash) +Shift": {
"1": {
"PRESS": "'",
"REPEAT": "'",
"RELEASE": null
}
},
"> (IntlBackslash) +Shift": {
"1": {
"PRESS": ">",
"REPEAT": ">",
"RELEASE": null
}
},
"; (Comma) +Shift": {
"1": {
"PRESS": ";",
"REPEAT": ";",
"RELEASE": null
}
},
": (Period) +Shift": {
"1": {
"PRESS": ":",
"REPEAT": ":",
"RELEASE": null
}
},
"_ (Slash) +Shift": {
"1": {
"PRESS": "_",
"REPEAT": "_",
"RELEASE": null
}
},
"ArrowUp +Shift": {
"1": {
"PRESS": "\u001b[1;2A",
"REPEAT": "\u001b[1;2A",
"RELEASE": null
}
},
"PageDown +Shift": {
"1": {
"PRESS": "\u001b[6;2~",
"REPEAT": "\u001b[6;2~",
"RELEASE": null
}
},
"Home +Shift": {
"1": {
"PRESS": "\u001b[1;2H",
"REPEAT": "\u001b[1;2H",
"RELEASE": null
}
},
"Home (Numpad7) +Shift": {
"1": {
"PRESS": "\u001b[57423;2u",
"REPEAT": "\u001b[57423;2u",
"RELEASE": null
}
},
"ArrowUp (Numapd8) +Shift": {
"1": {
"PRESS": "\u001b[57419;2u",
"REPEAT": "\u001b[57419;2u",
"RELEASE": null
}
},
"Clear (Numpad5) +Shift": {
"1": {
"PRESS": "\u001b[1;2E",
"REPEAT": "\u001b[1;2E",
"RELEASE": null
}
},
"Enter (NumpadEnter) +Shift": {
"1": {
"PRESS": "\u001b[57414;2u",
"REPEAT": "\u001b[57414;2u",
"RELEASE": null
}
},
"Insert (Numpad0) +Shift": {
"1": {
"PRESS": "\u001b[57425;2u",
"REPEAT": "\u001b[57425;2u",
"RELEASE": null
}
},
"\u0000 (NumpadDecimal) +Shift": {
"1": {
"PRESS": "\u001b[57426;2u",
"REPEAT": "\u001b[57426;2u",
"RELEASE": null
}
},
"Numlock +Shift": {
"1": {
"PRESS": null,
"REPEAT": null,
"RELEASE": null
}
},
"CapsLock +Shift": {
"1": {
"PRESS": null,
"REPEAT": null,
"RELEASE": null
}
},
"Shift (ShiftRight) +Shift": {
"1": {
"PRESS": null,
"REPEAT": null,
"RELEASE": null
}
},
"Control (ControlLeft) +Shift": {
"1": {
"PRESS": null,
"REPEAT": null,
"RELEASE": null
}
},
"Meta (MetaLeft) +Shift": {
"1": {
"PRESS": null,
"REPEAT": null,
"RELEASE": null
}
},
"Alt (AltLeft) +Shift": {
"1": {
"PRESS": null,
"REPEAT": null,
"RELEASE": null
}
},
" (Space) +Shift": {
"1": {
"PRESS": " ",
"REPEAT": " ",
"RELEASE": null
}
},
"AltGraph (AltRight) +Shift": {
"1": {
"PRESS": null,
"REPEAT": null,
"RELEASE": null
}
},
"Control (ControlRight) +Shift": {
"1": {
"PRESS": null,
"REPEAT": null,
"RELEASE": null
}
}
}
+706 -9
View File
File diff suppressed because it is too large Load Diff
+8
View File
@@ -56,6 +56,7 @@
"test-unit": "node ./bin/test_unit.js",
"test-unit-slow-tests": "npm run test-unit | grep \"ms)\"",
"test-unit-coverage": "node ./bin/test_unit.js --coverage",
"test-unit-dev": "cross-env NODE_PATH='./out' mocha",
"test-integration": "node ./bin/test_integration.js --workers=75%",
"test-integration-chromium": "node ./bin/test_integration.js --workers=75% \"--project=Chromium\"",
"test-integration-firefox": "node ./bin/test_integration.js --workers=75% \"--project=FirefoxStable\"",
@@ -79,11 +80,15 @@
"@playwright/test": "^1.57.0",
"@stylistic/eslint-plugin": "^4.4.1",
"@types/chai": "^4.2.22",
"@types/debug": "^4.1.7",
"@types/deep-equal": "^1.0.1",
"@types/express": "4",
"@types/express-ws": "^3.0.1",
"@types/jsdom": "^27.0.0",
"@types/mocha": "^9.0.0",
"@types/node": "^22.19.3",
"@types/trusted-types": "^1.0.6",
"@types/utf8": "^3.0.0",
"@types/webpack": "^5.28.0",
"@types/ws": "^8.2.0",
"@typescript-eslint/eslint-plugin": "^8.50.1",
@@ -91,6 +96,8 @@
"@typescript/native-preview": "^7.0.0-dev.20260213.1",
"chai": "^4.3.4",
"concurrently": "^9.1.2",
"cross-env": "^7.0.3",
"deep-equal": "^2.0.5",
"esbuild": "~0.25.2",
"eslint": "^9.39.2",
"eslint-plugin-jsdoc": "^50.8.0",
@@ -106,6 +113,7 @@
"ts-loader": "^9.3.1",
"typescript": "^5.9.3",
"typescript-eslint": "^8.50.1",
"utf8": "^3.0.0",
"webpack": "^5.61.0",
"webpack-cli": "^4.9.1",
"ws": "^8.2.3",
+1 -1
View File
@@ -18,7 +18,7 @@ export class MouseCoordsService implements IMouseCoordsService {
public getCoords(event: {clientX: number, clientY: number}, element: HTMLElement, colCount: number, rowCount: number, isSelection?: boolean): [number, number] | undefined {
return getCoords(
getWindow(element),
window,
event,
element,
colCount,
+1 -6
View File
@@ -29,7 +29,6 @@ export class RenderService extends Disposable implements IRenderService {
private _renderDebouncer: IRenderDebouncerWithCallback;
private _pausedResizeTask: DebouncedIdleTask;
private _observerDisposable = this._register(new MutableDisposable());
private _intersectionObserver: IntersectionObserver | undefined;
private _isPaused: boolean = false;
private _needsFullRefresh: boolean = false;
@@ -128,12 +127,8 @@ export class RenderService extends Disposable implements IRenderService {
// and resume based on terminal visibility if so
if ('IntersectionObserver' in w) {
const observer = new w.IntersectionObserver(e => this._handleIntersectionChange(e[e.length - 1]), { threshold: 0 });
this._observerDisposable.value = toDisposable(() => {
this._intersectionObserver?.disconnect();
this._intersectionObserver = undefined;
});
this._intersectionObserver = observer;
observer.observe(screenElement);
this._observerDisposable.value = toDisposable(() => observer.disconnect());
}
}
-36
View File
@@ -73,42 +73,6 @@ export class TimeoutTimer implements IDisposable {
}
}
/**
* Schedules a single runner on the microtask queue. Unlike {@link TimeoutTimer}, a scheduled
* microtask cannot be unqueued; {@link cancel} prevents the runner from executing if it has not
* run yet.
*/
export class MicrotaskTimer implements IDisposable {
private _isScheduled = false;
private _isDisposed = false;
public dispose(): void {
this.cancel();
this._isDisposed = true;
}
public cancel(): void {
this._isScheduled = false;
}
public set(runner: () => void): void {
if (this._isDisposed) {
throw new Error('Calling set on a disposed MicrotaskTimer');
}
if (this._isScheduled) {
return;
}
this._isScheduled = true;
queueMicrotask(() => {
if (!this._isScheduled) {
return;
}
this._isScheduled = false;
runner();
});
}
}
export class IntervalTimer implements IDisposable {
private _disposable: IDisposable | undefined;
private _isDisposed = false;
+5 -33
View File
@@ -5,6 +5,7 @@
import { assert } from 'chai';
import { StringToUtf32, stringFromCodePoint, Utf8ToUtf32, utf32ToString } from 'common/input/TextDecoder';
import { encode } from 'utf8';
// convert UTF32 codepoints to string
@@ -28,35 +29,6 @@ function fromByteString(s: string): Uint8Array {
return result;
}
function stringToUtf8Bytes(s: string): Uint8Array {
const bytes: number[] = [];
for (let i = 0; i < s.length; i++) {
let cp = s.charCodeAt(i);
if (cp >= 0xD800 && cp <= 0xDBFF && i + 1 < s.length) {
const next = s.charCodeAt(i + 1);
if (next >= 0xDC00 && next <= 0xDFFF) {
cp = 0x10000 + ((cp - 0xD800) << 10) + (next - 0xDC00);
i++;
}
}
if (cp < 0x80) {
bytes.push(cp);
} else if (cp < 0x800) {
bytes.push(0xC0 | (cp >> 6), 0x80 | (cp & 0x3F));
} else if (cp < 0x10000) {
bytes.push(0xE0 | (cp >> 12), 0x80 | ((cp >> 6) & 0x3F), 0x80 | (cp & 0x3F));
} else {
bytes.push(
0xF0 | (cp >> 18),
0x80 | ((cp >> 12) & 0x3F),
0x80 | ((cp >> 6) & 0x3F),
0x80 | (cp & 0x3F)
);
}
}
return new Uint8Array(bytes);
}
function assertDecodedRange(
min: number,
max: number,
@@ -200,7 +172,7 @@ describe('text encodings', () => {
max,
(i) => (i >= 0xD800 && i <= 0xDFFF) || i === 0xFEFF,
(i) => String.fromCharCode(i),
(input, target) => decoder.decode(stringToUtf8Bytes(input), target),
(input, target) => decoder.decode(fromByteString(encode(input)), target),
(data, length) => toString(data, length)
);
});
@@ -215,7 +187,7 @@ describe('text encodings', () => {
max,
() => false,
(i) => stringFromCodePoint(i),
(input, target) => decoder.decode(stringToUtf8Bytes(input), target),
(input, target) => decoder.decode(fromByteString(encode(input)), target),
(data, length) => toString(data, length)
);
});
@@ -224,7 +196,7 @@ describe('text encodings', () => {
it('0xFEFF(BOM)', () => {
const decoder = new Utf8ToUtf32();
const target = new Uint32Array(5);
const utf8Data = stringToUtf8Bytes(String.fromCharCode(0xFEFF));
const utf8Data = fromByteString(encode(String.fromCharCode(0xFEFF)));
const length = decoder.decode(utf8Data, target);
assert.equal(length, 0);
decoder.clear();
@@ -235,7 +207,7 @@ describe('text encodings', () => {
const decoder = new Utf8ToUtf32();
const target = new Uint32Array(500);
for (let i = 0; i < TEST_STRINGS.length; ++i) {
const utf8Data = stringToUtf8Bytes(TEST_STRINGS[i]);
const utf8Data = fromByteString(encode(TEST_STRINGS[i]));
const length = decoder.decode(utf8Data, target);
assert.equal(toString(target, length), TEST_STRINGS[i]);
decoder.clear();
+7 -107
View File
@@ -4,13 +4,11 @@
*/
import { assert } from 'chai';
import { DecorationLineCache, DecorationService } from './DecorationService';
import { DecorationService } from './DecorationService';
import { IMarker } from 'common/Types';
import { Disposable } from 'common/Lifecycle';
import { Emitter } from 'common/Event';
import { MockLogService, MockBufferService, MockOptionsService } from 'common/TestUtils.test';
import { Buffer } from 'common/buffer/Buffer';
import { DEFAULT_ATTR_DATA } from 'common/buffer/BufferLine';
import { MockLogService } from 'common/TestUtils.test';
function createFakeMarker(line: number): IMarker {
return Object.freeze(new class extends Disposable {
@@ -21,16 +19,11 @@ function createFakeMarker(line: number): IMarker {
}());
}
function createDecorationService(): DecorationService {
const bufferService = new MockBufferService(80, 24, new MockOptionsService());
return new DecorationService(new MockLogService(), bufferService);
}
const fakeMarker: IMarker = createFakeMarker(1);
describe('DecorationService', () => {
it('should set isDisposed to true after dispose', () => {
const service = createDecorationService();
const service = new DecorationService(new MockLogService());
const decoration = service.registerDecoration({
marker: fakeMarker
});
@@ -42,7 +35,7 @@ describe('DecorationService', () => {
describe('forEachDecorationAtCell', () => {
it('should find decoration at its marker line', () => {
const service = createDecorationService();
const service = new DecorationService(new MockLogService());
const decoration = service.registerDecoration({
marker: createFakeMarker(5),
width: 10
@@ -55,7 +48,7 @@ describe('DecorationService', () => {
});
it('should find decoration with height > 1 on subsequent lines', () => {
const service = createDecorationService();
const service = new DecorationService(new MockLogService());
const decoration = service.registerDecoration({
marker: createFakeMarker(5),
width: 10,
@@ -81,7 +74,7 @@ describe('DecorationService', () => {
});
it('should not find decoration outside its x range', () => {
const service = createDecorationService();
const service = new DecorationService(new MockLogService());
const decoration = service.registerDecoration({
marker: createFakeMarker(5),
x: 5,
@@ -106,35 +99,11 @@ describe('DecorationService', () => {
service.forEachDecorationAtCell(8, 5, undefined, d => foundAtX8.push(d));
assert.strictEqual(foundAtX8.length, 0);
});
it('should find multi-line decoration when single-line decorations exist on other lines', () => {
const bufferService = new MockBufferService(80, 24, new MockOptionsService());
const serviceWithBuffer = new DecorationService(new MockLogService(), bufferService);
const buffer = bufferService.buffer;
(buffer as Buffer).fillViewportRows();
for (let i = 0; i < 100; i++) {
serviceWithBuffer.registerDecoration({
marker: buffer.addMarker(i),
width: 5
});
}
const multiLine = serviceWithBuffer.registerDecoration({
marker: buffer.addMarker(10),
width: 10,
height: 3
});
assert.ok(multiLine);
const found: typeof multiLine[] = [];
serviceWithBuffer.forEachDecorationAtCell(0, 11, undefined, d => found.push(d));
assert.include(found, multiLine);
});
});
describe('getDecorationsAtCell', () => {
it('should find decoration with height > 1 on subsequent lines', () => {
const service = createDecorationService();
const service = new DecorationService(new MockLogService());
const decoration = service.registerDecoration({
marker: createFakeMarker(5),
width: 10,
@@ -148,73 +117,4 @@ describe('DecorationService', () => {
assert.strictEqual([...service.getDecorationsAtCell(0, 8)].length, 0);
});
});
describe('DecorationLineCache', () => {
it('should return undefined for lines with no indexed decorations', () => {
const cache = new DecorationLineCache();
assert.isUndefined(cache.getDecorationsOnLine(0));
});
});
describe('line index maintenance', () => {
it('should keep lookups correct after buffer trim', () => {
const bufferService = new MockBufferService(80, 5, new MockOptionsService({ scrollback: 0 }));
const service = new DecorationService(new MockLogService(), bufferService);
const buffer = bufferService.buffer;
(buffer as Buffer).fillViewportRows();
const marker = buffer.addMarker(buffer.lines.length - 1);
const decoration = service.registerDecoration({ marker, width: 10 });
assert.ok(decoration);
buffer.lines.onTrimEmitter.fire(1);
const found: typeof decoration[] = [];
service.forEachDecorationAtCell(0, marker.line, undefined, d => found.push(d));
assert.strictEqual(found.length, 1);
});
it('should remove decoration from line index when marker is trimmed off buffer', () => {
const bufferService = new MockBufferService(80, 5, new MockOptionsService({ scrollback: 0 }));
const service = new DecorationService(new MockLogService(), bufferService);
const buffer = bufferService.buffer;
(buffer as Buffer).fillViewportRows();
const marker = buffer.addMarker(0);
const decoration = service.registerDecoration({ marker, width: 10 });
assert.ok(decoration);
buffer.lines.onTrimEmitter.fire(1);
assert.isTrue(marker.isDisposed);
assert.isTrue(decoration!.isDisposed);
const found: typeof decoration[] = [];
service.forEachDecorationAtCell(0, 0, undefined, d => found.push(d));
assert.strictEqual(found.length, 0);
});
it('should keep multi-line decoration indexed after line insert', async () => {
const bufferService = new MockBufferService(80, 10, new MockOptionsService({ scrollback: 100 }));
const service = new DecorationService(new MockLogService(), bufferService);
const buffer = bufferService.buffer;
(buffer as Buffer).fillViewportRows();
const marker = buffer.addMarker(3);
const decoration = service.registerDecoration({ marker, width: 10, height: 3 });
assert.ok(decoration);
buffer.lines.splice(5, 0, buffer.getBlankLine(DEFAULT_ATTR_DATA));
await new Promise<void>(resolve => queueMicrotask(resolve));
const foundOnSpan: typeof decoration[] = [];
for (let line = marker.line; line < marker.line + 3; line++) {
service.forEachDecorationAtCell(0, line, undefined, d => foundOnSpan.push(d));
}
assert.include(foundOnSpan, decoration);
const foundOutsideSpan: typeof decoration[] = [];
service.forEachDecorationAtCell(0, marker.line + 3, undefined, d => foundOutsideSpan.push(d));
assert.strictEqual(foundOutsideSpan.length, 0);
});
});
});
+20 -254
View File
@@ -3,19 +3,19 @@
* @license MIT
*/
import type { IDeleteEvent, IInsertEvent } from 'common/CircularList';
import { MicrotaskTimer } from 'common/Async';
import { css } from 'common/Color';
import { Disposable, DisposableStore, MutableDisposable, toDisposable } from 'common/Lifecycle';
import { IBufferService, IDecorationService, IInternalDecoration, ILogService } from 'common/services/Services';
import { Disposable, DisposableStore, toDisposable } from 'common/Lifecycle';
import { IDecorationService, IInternalDecoration, ILogService } from 'common/services/Services';
import { SortedList } from 'common/SortedList';
import { IColor, ICircularList } from 'common/Types';
import { IColor } from 'common/Types';
import { IDecoration, IDecorationOptions, IMarker } from '@xterm/xterm';
import { Emitter } from 'common/Event';
// Work variables to avoid garbage collection
let $xmin = 0;
let $xmax = 0;
let $ymin = 0;
let $ymax = 0;
export class DecorationService extends Disposable implements IDecorationService {
public serviceBrand: any;
@@ -27,8 +27,6 @@ export class DecorationService extends Disposable implements IDecorationService
*/
private readonly _decorations: SortedList<IInternalDecoration>;
private readonly _lineCache = this._register(new DecorationLineCache());
private readonly _onDecorationRegistered = this._register(new Emitter<IInternalDecoration>());
public readonly onDecorationRegistered = this._onDecorationRegistered.event;
private readonly _onDecorationRemoved = this._register(new Emitter<IInternalDecoration>());
@@ -36,19 +34,12 @@ export class DecorationService extends Disposable implements IDecorationService
public get decorations(): IterableIterator<IInternalDecoration> { return this._decorations.values(); }
constructor(
@ILogService private readonly _logService: ILogService,
@IBufferService private readonly _bufferService: IBufferService
) {
constructor(@ILogService private readonly _logService: ILogService) {
super();
this._decorations = new SortedList(e => e?.marker.line, this._logService);
this._register(toDisposable(() => this.reset()));
this._register(this._bufferService.buffers.onBufferActivate(() => {
this._lineCache.attachToBufferLines(this._bufferService.buffer.lines);
}));
this._lineCache.attachToBufferLines(this._bufferService.buffer.lines);
}
public registerDecoration(options: IDecorationOptions): IDecoration | undefined {
@@ -62,14 +53,12 @@ export class DecorationService extends Disposable implements IDecorationService
listener.dispose();
if (decoration) {
if (this._decorations.delete(decoration)) {
this._lineCache.remove(decoration);
this._onDecorationRemoved.fire(decoration);
}
markerDispose.dispose();
}
});
this._decorations.insert(decoration);
this._lineCache.add(decoration);
this._onDecorationRegistered.fire(decoration);
}
return decoration;
@@ -80,17 +69,19 @@ export class DecorationService extends Disposable implements IDecorationService
d.dispose();
}
this._decorations.clear();
this._lineCache.clear();
}
public *getDecorationsAtCell(x: number, line: number, layer?: 'bottom' | 'top'): IterableIterator<IInternalDecoration> {
const bucket = this._lineCache.getDecorationsOnLine(line);
if (!bucket) {
return;
}
let xmin = 0;
let xmax = 0;
for (const d of bucket) {
let ymin = 0;
let ymax = 0;
for (const d of this._decorations.values()) {
ymin = d.marker.line;
ymax = ymin + (d.options.height ?? 1);
if (line < ymin || line >= ymax) {
continue;
}
xmin = d.options.x ?? 0;
xmax = xmin + (d.options.width ?? 1);
if (x >= xmin && x < xmax && (!layer || (d.options.layer ?? 'bottom') === layer)) {
@@ -100,11 +91,12 @@ export class DecorationService extends Disposable implements IDecorationService
}
public forEachDecorationAtCell(x: number, line: number, layer: 'bottom' | 'top' | undefined, callback: (decoration: IInternalDecoration) => void): void {
const bucket = this._lineCache.getDecorationsOnLine(line);
if (!bucket) {
return;
}
for (const d of bucket) {
for (const d of this._decorations.values()) {
$ymin = d.marker.line;
$ymax = $ymin + (d.options.height ?? 1);
if (line < $ymin || line >= $ymax) {
continue;
}
$xmin = d.options.x ?? 0;
$xmax = $xmin + (d.options.width ?? 1);
if (x >= $xmin && x < $xmax && (!layer || (d.options.layer ?? 'bottom') === layer)) {
@@ -114,235 +106,10 @@ export class DecorationService extends Disposable implements IDecorationService
}
}
/**
* Per-logical-line index of decorations for fast cell lookup.
*
* Keys are marker.line coordinates (logical buffer lines), not CircularList ring slots.
* Multi-line decorations appear in every line bucket they span. The index is kept aligned
* with marker.line updates via buffer line trim/insert/delete events.
*/
export class DecorationLineCache extends Disposable {
private readonly _decorationsByLine: Map<number, IInternalDecoration[]> = new Map();
private readonly _decorations = new Set<IInternalDecoration>();
private readonly _bufferLineListeners = this._register(new MutableDisposable<DisposableStore>());
private readonly _lineIndexSyncTimer = this._register(new MicrotaskTimer());
private _lineIndexSyncCallbacks: (() => void)[] = [];
public clear(): void {
this._lineIndexSyncCallbacks.length = 0;
this._lineIndexSyncTimer.cancel();
this._decorationsByLine.clear();
this._decorations.clear();
}
public add(decoration: IInternalDecoration): void {
this._decorations.add(decoration);
this._addToLineBuckets(decoration);
}
public remove(decoration: IInternalDecoration): void {
this._decorations.delete(decoration);
this._removeFromLineBuckets(decoration);
}
public getDecorationsOnLine(line: number): ReadonlyArray<IInternalDecoration> | undefined {
return this._decorationsByLine.get(line);
}
public attachToBufferLines(lines: ICircularList<unknown>): void {
const store = new DisposableStore();
this._bufferLineListeners.value = store;
store.add(lines.onTrim(amount => this._handleBufferLinesTrim(amount)));
store.add(lines.onInsert(event => this._handleBufferLinesInsert(event)));
store.add(lines.onDelete(event => this._handleBufferLinesDelete(event)));
}
private _getDecorationHeight(decoration: IInternalDecoration): number {
return decoration.options.height ?? 1;
}
private _addToLineBuckets(decoration: IInternalDecoration): void {
const start = decoration.marker.line;
if (start < 0) {
return;
}
decoration._indexedStartLine = start;
const height = this._getDecorationHeight(decoration);
for (let line = start; line < start + height; line++) {
let bucket = this._decorationsByLine.get(line);
if (!bucket) {
bucket = [];
this._decorationsByLine.set(line, bucket);
}
bucket.push(decoration);
}
}
private _removeFromLineBuckets(decoration: IInternalDecoration): void {
const start = decoration._indexedStartLine;
const height = this._getDecorationHeight(decoration);
for (let line = start; line < start + height; line++) {
const bucket = this._decorationsByLine.get(line);
if (!bucket) {
continue;
}
const index = bucket.indexOf(decoration);
if (index !== -1) {
bucket.splice(index, 1);
}
if (bucket.length === 0) {
this._decorationsByLine.delete(line);
}
}
}
private _reindexDecoration(decoration: IInternalDecoration): void {
this._removeFromLineBuckets(decoration);
if (!decoration.marker.isDisposed && decoration.marker.line >= 0) {
this._addToLineBuckets(decoration);
}
}
/** Re-index after marker line updates (buffer listeners may run before markers). */
private _scheduleLineIndexSync(callback: () => void): void {
this._lineIndexSyncCallbacks.push(callback);
this._lineIndexSyncTimer.set(() => {
const callbacks = this._lineIndexSyncCallbacks;
this._lineIndexSyncCallbacks = [];
for (const cb of callbacks) {
cb();
}
});
}
private _handleBufferLinesTrim(amount: number): void {
if (amount <= 0) {
return;
}
const newMap = new Map<number, IInternalDecoration[]>();
for (const [line, bucket] of this._decorationsByLine) {
const newLine = line - amount;
if (newLine < 0) {
continue;
}
const existing = newMap.get(newLine);
if (existing) {
existing.push(...bucket);
} else {
newMap.set(newLine, bucket.slice());
}
}
this._decorationsByLine.clear();
for (const [line, bucket] of newMap) {
this._decorationsByLine.set(line, bucket);
}
for (const d of this._decorations) {
if (!d.marker.isDisposed) {
d._indexedStartLine -= amount;
}
}
}
private _handleBufferLinesInsert(event: IInsertEvent): void {
this._scheduleLineIndexSync(() => this._applyBufferLinesInsert(event));
}
private _handleBufferLinesDelete(event: IDeleteEvent): void {
this._scheduleLineIndexSync(() => this._applyBufferLinesDelete(event));
}
private _mergeLineBucket(newMap: Map<number, IInternalDecoration[]>, line: number, bucket: IInternalDecoration[]): void {
const existing = newMap.get(line);
if (existing) {
existing.push(...bucket);
} else {
newMap.set(line, bucket.slice());
}
}
/**
* Shift indexed line keys and sync start lines. O(unique indexed lines), not O(decoration count).
* Decorations that span the insert point are re-indexed individually (rare vs single-line hits).
*/
private _applyBufferLinesInsert(event: IInsertEvent): void {
const { index, amount } = event;
const spanCrossers: IInternalDecoration[] = [];
for (const d of this._decorations) {
if (d.marker.isDisposed) {
continue;
}
const start = d._indexedStartLine;
if (start < index && start + this._getDecorationHeight(d) > index) {
spanCrossers.push(d);
this._removeFromLineBuckets(d);
}
}
const newMap = new Map<number, IInternalDecoration[]>();
for (const [line, bucket] of this._decorationsByLine) {
const newLine = line >= index ? line + amount : line;
this._mergeLineBucket(newMap, newLine, bucket);
}
this._decorationsByLine.clear();
for (const [line, bucket] of newMap) {
this._decorationsByLine.set(line, bucket);
}
for (const d of this._decorations) {
if (d.marker.isDisposed) {
continue;
}
if (d._indexedStartLine >= index) {
d._indexedStartLine = d.marker.line;
}
}
for (const d of spanCrossers) {
this._addToLineBuckets(d);
}
}
/**
* Drop deleted line keys, shift keys below, sync start lines. Full re-index only when a
* multi-line decoration spans across the deleted range but survives.
*/
private _applyBufferLinesDelete(event: IDeleteEvent): void {
const deleteEnd = event.index + event.amount;
const newMap = new Map<number, IInternalDecoration[]>();
for (const [line, bucket] of this._decorationsByLine) {
if (line >= event.index && line < deleteEnd) {
continue;
}
const newLine = line >= deleteEnd ? line - event.amount : line;
this._mergeLineBucket(newMap, newLine, bucket);
}
this._decorationsByLine.clear();
for (const [line, bucket] of newMap) {
this._decorationsByLine.set(line, bucket);
}
const toReindex: IInternalDecoration[] = [];
for (const d of this._decorations) {
if (d.marker.isDisposed) {
continue;
}
const start = d._indexedStartLine;
const height = this._getDecorationHeight(d);
if (start >= deleteEnd) {
d._indexedStartLine = d.marker.line;
} else if (start < event.index && start + height > deleteEnd) {
toReindex.push(d);
}
}
for (const d of toReindex) {
this._reindexDecoration(d);
}
}
}
class Decoration extends DisposableStore implements IInternalDecoration {
public readonly marker: IMarker;
public element: HTMLElement | undefined;
/** Start line used for line-index removal when marker.line is cleared on dispose. */
public _indexedStartLine: number;
public readonly onRenderEmitter = this.add(new Emitter<HTMLElement>());
public readonly onRender = this.onRenderEmitter.event;
private readonly _onDispose = this.add(new Emitter<void>());
@@ -377,7 +144,6 @@ class Decoration extends DisposableStore implements IInternalDecoration {
) {
super();
this.marker = options.marker;
this._indexedStartLine = options.marker.line;
if (this.options.overviewRulerOptions && !this.options.overviewRulerOptions.position) {
this.options.overviewRulerOptions.position = 'full';
}
-2
View File
@@ -396,6 +396,4 @@ export interface IInternalDecoration extends IDecoration {
readonly backgroundColorRGB: IColor | undefined;
readonly foregroundColorRGB: IColor | undefined;
readonly onRenderEmitter: Emitter<HTMLElement>;
/** @internal Start line for line-index removal; kept in sync on buffer line shifts. */
_indexedStartLine: number;
}
@@ -1,109 +0,0 @@
/**
* Copyright (c) 2026 The xterm.js authors. All rights reserved.
* @license MIT
*/
import { perfContext, before, RuntimeCase } from 'xterm-benchmark';
import { DecorationService } from 'common/services/DecorationService';
import { MockLogService, MockBufferService, MockOptionsService } from 'common/TestUtils.test';
const enum Constants {
COLS = 80,
ROWS = 30,
SINGLE_LINE_DECORATION_COUNT = 20_000,
MULTI_LINE_DECORATION_COUNT = 19_999,
MULTI_LINE_HEIGHT = 2,
VIEWPORT_SCAN_ITERATIONS = 20
}
function registerSingleLineDecorations(service: DecorationService, bufferService: MockBufferService, count: number): void {
const buffer = bufferService.buffer;
for (let i = 0; i < count; i++) {
const line = i % 5000;
const marker = buffer.addMarker(line);
service.registerDecoration({ marker, width: Constants.COLS });
}
}
function registerMixedDecorations(service: DecorationService, bufferService: MockBufferService): void {
const buffer = bufferService.buffer;
for (let i = 0; i < Constants.MULTI_LINE_DECORATION_COUNT; i++) {
const line = i % 5000;
const marker = buffer.addMarker(line);
service.registerDecoration({ marker, width: Constants.COLS });
}
const multiLineMarker = buffer.addMarker(10);
service.registerDecoration({
marker: multiLineMarker,
width: Constants.COLS,
height: Constants.MULTI_LINE_HEIGHT
});
}
function scanVisibleGrid(service: DecorationService, rows: number, cols: number): number {
let hitCount = 0;
for (let row = 0; row < rows; row++) {
for (let col = 0; col < cols; col++) {
service.forEachDecorationAtCell(col, row, undefined, () => {
hitCount++;
});
}
}
return hitCount;
}
perfContext('DecorationService.forEachDecorationAtCell', () => {
perfContext('single-line dense / sparse line hit', () => {
let service: DecorationService;
let bufferService: MockBufferService;
before(() => {
bufferService = new MockBufferService(Constants.COLS, Constants.ROWS, new MockOptionsService());
service = new DecorationService(new MockLogService(), bufferService);
registerSingleLineDecorations(service, bufferService, Constants.SINGLE_LINE_DECORATION_COUNT);
});
new RuntimeCase('', () => {
let hitCount = 0;
for (let i = 0; i < Constants.VIEWPORT_SCAN_ITERATIONS; i++) {
service.forEachDecorationAtCell(0, 0, undefined, () => {
hitCount++;
});
}
return { payloadSize: Constants.VIEWPORT_SCAN_ITERATIONS, hitCount };
}, { fork: false }).showAverageRuntime();
});
perfContext('mixed single-line + multi-line / sparse line hit', () => {
let service: DecorationService;
let bufferService: MockBufferService;
before(() => {
bufferService = new MockBufferService(Constants.COLS, Constants.ROWS, new MockOptionsService());
service = new DecorationService(new MockLogService(), bufferService);
registerMixedDecorations(service, bufferService);
});
new RuntimeCase('', () => {
let hitCount = 0;
for (let i = 0; i < Constants.VIEWPORT_SCAN_ITERATIONS; i++) {
service.forEachDecorationAtCell(0, 10, undefined, () => {
hitCount++;
});
}
return { payloadSize: Constants.VIEWPORT_SCAN_ITERATIONS, hitCount };
}, { fork: false }).showAverageRuntime();
});
perfContext('viewport grid scan', () => {
let service: DecorationService;
let bufferService: MockBufferService;
before(() => {
bufferService = new MockBufferService(Constants.COLS, Constants.ROWS, new MockOptionsService());
service = new DecorationService(new MockLogService(), bufferService);
registerSingleLineDecorations(service, bufferService, Constants.SINGLE_LINE_DECORATION_COUNT);
});
new RuntimeCase('', () => {
let totalHits = 0;
for (let i = 0; i < Constants.VIEWPORT_SCAN_ITERATIONS; i++) {
totalHits += scanVisibleGrid(service, Constants.ROWS, Constants.COLS);
}
return { payloadSize: Constants.VIEWPORT_SCAN_ITERATIONS * Constants.ROWS * Constants.COLS, totalHits };
}, { fork: false }).showAverageRuntime();
});
});