Compare commits
8
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2f49f321c1 | ||
|
|
c0007301c1 | ||
|
|
0d8996395c | ||
|
|
e24b043ab2 | ||
|
|
6f15b0f916 | ||
|
|
33eb1dd45e | ||
|
|
0ca8107055 | ||
|
|
d844070855 |
@@ -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/
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,3 +1,2 @@
|
||||
lib
|
||||
node_modules
|
||||
out-benchmark
|
||||
node_modules
|
||||
@@ -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" }
|
||||
]
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+202
-1201
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
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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 },
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
"include": [],
|
||||
"references": [
|
||||
{ "path": "./src" },
|
||||
{ "path": "./test" },
|
||||
{ "path": "./benchmark" }
|
||||
{ "path": "./test" }
|
||||
]
|
||||
}
|
||||
|
||||
+12
-14
@@ -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>;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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++;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
Generated
+706
-9
File diff suppressed because it is too large
Load Diff
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,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();
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user