Compare commits
15
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6e39f74f2c | ||
|
|
9a64c2cb63 | ||
|
|
dc2ac0b78b | ||
|
|
e969832946 | ||
|
|
95a9531ec1 | ||
|
|
7637a2de19 | ||
|
|
a589be2826 | ||
|
|
b53096f4a2 | ||
|
|
687d8277e7 | ||
|
|
4c0cf27cfc | ||
|
|
149527ddb8 | ||
|
|
c4b707e9ca | ||
|
|
9d35d59fb3 | ||
|
|
3ad83dd70e | ||
|
|
496b53d84d |
@@ -72,6 +72,7 @@ export class SearchAddon extends Disposable implements ITerminalAddon , ISearchA
|
||||
private _cachedSearchTerm: string | undefined;
|
||||
private _highlightedLines: Set<number> = new Set();
|
||||
private _highlightDecorations: IHighlight[] = [];
|
||||
private _searchResultsWithHighlight: ISearchResult[] = [];
|
||||
private _selectedDecoration: MutableDisposable<IMultiHighlight> = this._register(new MutableDisposable());
|
||||
private _highlightLimit: number;
|
||||
private _lastSearchOptions: ISearchOptions | undefined;
|
||||
@@ -118,6 +119,7 @@ export class SearchAddon extends Disposable implements ITerminalAddon , ISearchA
|
||||
this._selectedDecoration.clear();
|
||||
dispose(this._highlightDecorations);
|
||||
this._highlightDecorations = [];
|
||||
this._searchResultsWithHighlight = [];
|
||||
this._highlightedLines.clear();
|
||||
if (!retainCachedSearchTerm) {
|
||||
this._cachedSearchTerm = undefined;
|
||||
@@ -167,15 +169,14 @@ export class SearchAddon extends Disposable implements ITerminalAddon , ISearchA
|
||||
// new search, clear out the old decorations
|
||||
this.clearDecorations(true);
|
||||
|
||||
const searchResultsWithHighlight: ISearchResult[] = [];
|
||||
let prevResult: ISearchResult | undefined = undefined;
|
||||
let result = this._find(term, 0, 0, searchOptions);
|
||||
while (result && (prevResult?.row !== result.row || prevResult?.col !== result.col)) {
|
||||
if (searchResultsWithHighlight.length >= this._highlightLimit) {
|
||||
if (this._searchResultsWithHighlight.length >= this._highlightLimit) {
|
||||
break;
|
||||
}
|
||||
prevResult = result;
|
||||
searchResultsWithHighlight.push(prevResult);
|
||||
this._searchResultsWithHighlight.push(prevResult);
|
||||
result = this._find(
|
||||
term,
|
||||
prevResult.col + prevResult.term.length >= this._terminal.cols ? prevResult.row + 1 : prevResult.row,
|
||||
@@ -183,7 +184,7 @@ export class SearchAddon extends Disposable implements ITerminalAddon , ISearchA
|
||||
searchOptions
|
||||
);
|
||||
}
|
||||
for (const match of searchResultsWithHighlight) {
|
||||
for (const match of this._searchResultsWithHighlight) {
|
||||
const decorations = this._createResultDecorations(match, searchOptions.decorations!, false);
|
||||
if (decorations) {
|
||||
for (const decoration of decorations) {
|
||||
@@ -350,15 +351,15 @@ export class SearchAddon extends Disposable implements ITerminalAddon , ISearchA
|
||||
let resultIndex = -1;
|
||||
if (this._selectedDecoration.value) {
|
||||
const selectedMatch = this._selectedDecoration.value.match;
|
||||
for (let i = 0; i < this._highlightDecorations.length; i++) {
|
||||
const match = this._highlightDecorations[i].match;
|
||||
for (let i = 0; i < this._searchResultsWithHighlight.length; i++) {
|
||||
const match = this._searchResultsWithHighlight[i];
|
||||
if (match.row === selectedMatch.row && match.col === selectedMatch.col && match.size === selectedMatch.size) {
|
||||
resultIndex = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
this._onDidChangeResults.fire({ resultIndex, resultCount: this._highlightDecorations.length });
|
||||
this._onDidChangeResults.fire({ resultIndex, resultCount: this._searchResultsWithHighlight.length });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -480,6 +480,57 @@ test.describe('Search Tests', () => {
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Wrapped line search functionality', () => {
|
||||
test('should correctly count matches across multiple wrapped lines', async () => {
|
||||
await ctx.page.evaluate(`
|
||||
window.calls = [];
|
||||
window.search.onDidChangeResults(e => window.calls.push(e));
|
||||
`);
|
||||
|
||||
const content = 'a'.repeat(300);
|
||||
await ctx.proxy.write(content);
|
||||
strictEqual(await ctx.page.evaluate(`window.search.findNext('${content}', { decorations: { activeMatchColorOverviewRuler: '#ff0000', matchOverviewRuler: '#ffff00' } })`), true);
|
||||
deepStrictEqual(await ctx.page.evaluate('window.calls'), [
|
||||
{ resultCount: 1, resultIndex: 0 }
|
||||
]);
|
||||
});
|
||||
|
||||
test('should handle reverse search across wrapped lines', async () => {
|
||||
await ctx.page.evaluate(`
|
||||
window.calls = [];
|
||||
window.search.onDidChangeResults(e => window.calls.push(e));
|
||||
`);
|
||||
|
||||
const content = 'x'.repeat(300);
|
||||
await ctx.proxy.write(content);
|
||||
strictEqual(await ctx.page.evaluate(`window.search.findPrevious('${content}', { decorations: { activeMatchColorOverviewRuler: '#ff0000', matchOverviewRuler: '#ffff00' } })`), true);
|
||||
deepStrictEqual(await ctx.page.evaluate('window.calls'), [
|
||||
{ resultCount: 1, resultIndex: 0 }
|
||||
]);
|
||||
});
|
||||
|
||||
test('should update counts when content changes across wrapped lines', async () => {
|
||||
await ctx.page.evaluate(`
|
||||
window.calls = [];
|
||||
window.search.onDidChangeResults(e => window.calls.push(e));
|
||||
`);
|
||||
|
||||
const content = 'z'.repeat(300);
|
||||
await ctx.proxy.write(content);
|
||||
strictEqual(await ctx.page.evaluate(`window.search.findNext('${content}', { decorations: { activeMatchColorOverviewRuler: '#ff0000', matchOverviewRuler: '#ffff00' } })`), true);
|
||||
deepStrictEqual(await ctx.page.evaluate('window.calls'), [
|
||||
{ resultCount: 1, resultIndex: 0 }
|
||||
]);
|
||||
|
||||
await ctx.proxy.write('\\n\\r' + content);
|
||||
await timeout(300);
|
||||
deepStrictEqual(await ctx.page.evaluate('window.calls'), [
|
||||
{ resultCount: 1, resultIndex: 0 },
|
||||
{ resultCount: 2, resultIndex: 0 }
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
function makeData(length: number): string {
|
||||
|
||||
@@ -139,7 +139,7 @@ export class WebglRenderer extends Disposable implements IRenderer {
|
||||
|
||||
[this._rectangleRenderer.value, this._glyphRenderer.value] = this._initializeWebGLState();
|
||||
|
||||
this._isAttached = this._coreBrowserService.window.document.body.contains(this._core.screenElement!);
|
||||
this._isAttached = this._core.screenElement!.isConnected;
|
||||
|
||||
this._register(toDisposable(() => {
|
||||
for (const l of this._renderLayers) {
|
||||
@@ -322,7 +322,7 @@ export class WebglRenderer extends Disposable implements IRenderer {
|
||||
|
||||
public renderRows(start: number, end: number): void {
|
||||
if (!this._isAttached) {
|
||||
if (this._coreBrowserService.window.document.body.contains(this._core.screenElement!) && this._charSizeService.width && this._charSizeService.height) {
|
||||
if (this._core.screenElement?.isConnected && this._charSizeService.width && this._charSizeService.height) {
|
||||
this._updateDimensions();
|
||||
this._refreshCharAtlas();
|
||||
this._isAttached = true;
|
||||
|
||||
@@ -199,7 +199,9 @@ function bufferLine(
|
||||
let currentRow = startRow;
|
||||
let bufferStr = '';
|
||||
|
||||
while (currentCol !== endCol || currentRow !== endRow) {
|
||||
while ((currentCol !== endCol || currentRow !== endRow) &&
|
||||
currentRow >= 0 &&
|
||||
currentRow < bufferService.buffer.lines.length) {
|
||||
currentCol += forward ? 1 : -1;
|
||||
|
||||
if (forward && currentCol > bufferService.cols - 1) {
|
||||
|
||||
@@ -0,0 +1,169 @@
|
||||
/**
|
||||
* Test for Alt+- keyboard handling specifically
|
||||
* This tests both the evaluateKeyboardEvent function and potential browser integration issues
|
||||
*/
|
||||
|
||||
import { assert } from 'chai';
|
||||
import { evaluateKeyboardEvent } from 'common/input/Keyboard';
|
||||
import { IKeyboardResult, IKeyboardEvent } from 'common/Types';
|
||||
|
||||
function createMockKeyboardEvent(partialEvent: Partial<IKeyboardEvent>): IKeyboardEvent {
|
||||
return {
|
||||
altKey: false,
|
||||
ctrlKey: false,
|
||||
shiftKey: false,
|
||||
metaKey: false,
|
||||
keyCode: 0,
|
||||
code: '',
|
||||
key: '',
|
||||
type: 'keydown',
|
||||
...partialEvent
|
||||
};
|
||||
}
|
||||
|
||||
describe('Alt+- keyboard handling', () => {
|
||||
it('should return correct escape sequence for Alt+- on all platforms', () => {
|
||||
const event = createMockKeyboardEvent({
|
||||
altKey: true,
|
||||
keyCode: 189,
|
||||
key: '-',
|
||||
type: 'keydown'
|
||||
});
|
||||
|
||||
// Test on non-Mac (Linux/Windows)
|
||||
const resultLinux = evaluateKeyboardEvent(event, false, false, false);
|
||||
assert.equal(resultLinux.key, '\x1b-', 'Alt+- should produce \\x1b- on Linux');
|
||||
assert.equal(resultLinux.cancel, false, 'Alt+- should not be cancelled on Linux');
|
||||
|
||||
// Test on Windows
|
||||
const resultWindows = evaluateKeyboardEvent(event, false, false, false);
|
||||
assert.equal(resultWindows.key, '\x1b-', 'Alt+- should produce \\x1b- on Windows');
|
||||
|
||||
// Test on Mac with macOptionIsMeta: true
|
||||
const resultMacMeta = evaluateKeyboardEvent(event, false, true, true);
|
||||
assert.equal(resultMacMeta.key, '\x1b-', 'Alt+- should produce \\x1b- on Mac with macOptionIsMeta');
|
||||
|
||||
// Test on Mac with macOptionIsMeta: false (third level shift)
|
||||
const resultMacNoMeta = evaluateKeyboardEvent(event, false, true, false);
|
||||
assert.equal(resultMacNoMeta.key, undefined, 'Alt+- should not produce escape sequence on Mac without macOptionIsMeta');
|
||||
});
|
||||
|
||||
it('should handle Alt+- with shift key (underscore)', () => {
|
||||
const event = createMockKeyboardEvent({
|
||||
altKey: true,
|
||||
shiftKey: true,
|
||||
keyCode: 189,
|
||||
key: '_',
|
||||
type: 'keydown'
|
||||
});
|
||||
|
||||
const result = evaluateKeyboardEvent(event, false, false, false);
|
||||
assert.equal(result.key, '\x1b_', 'Alt+Shift+- should produce \\x1b_');
|
||||
});
|
||||
|
||||
it('should verify keyCode 189 maps to minus character', () => {
|
||||
// This test verifies that keyCode 189 is correctly mapped in KEYCODE_KEY_MAPPINGS
|
||||
const event = createMockKeyboardEvent({
|
||||
altKey: true,
|
||||
keyCode: 189,
|
||||
key: '-',
|
||||
type: 'keydown'
|
||||
});
|
||||
|
||||
const result = evaluateKeyboardEvent(event, false, false, false);
|
||||
assert.isDefined(result.key, 'Result should have a key');
|
||||
assert.equal(result.key, '\x1b-', 'Should produce the correct escape sequence');
|
||||
});
|
||||
|
||||
it('should handle various browser scenarios for Alt+-', () => {
|
||||
// Test with different keyCode values that might be used for minus
|
||||
const keyCodes = [189, 109]; // 189 is standard minus, 109 is numpad minus
|
||||
|
||||
keyCodes.forEach(keyCode => {
|
||||
const event = createMockKeyboardEvent({
|
||||
altKey: true,
|
||||
keyCode: keyCode,
|
||||
key: '-',
|
||||
type: 'keydown'
|
||||
});
|
||||
|
||||
const result = evaluateKeyboardEvent(event, false, false, false);
|
||||
if (keyCode === 189) {
|
||||
assert.equal(result.key, '\x1b-', `keyCode ${keyCode} should produce \\x1b-`);
|
||||
} else {
|
||||
// For numpad minus or other codes, the behavior might be different
|
||||
// but we should still handle them gracefully
|
||||
assert.isDefined(result, `keyCode ${keyCode} should return a result`);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it('should not be affected by composition state for regular Alt combinations', () => {
|
||||
// This tests that Alt+- is not treated as a composition character
|
||||
const event = createMockKeyboardEvent({
|
||||
altKey: true,
|
||||
keyCode: 189,
|
||||
key: '-',
|
||||
type: 'keydown'
|
||||
});
|
||||
|
||||
// Ensure the keyCode is not 229 (composition character)
|
||||
assert.notEqual(event.keyCode, 229, 'Alt+- should not use composition keyCode');
|
||||
|
||||
const result = evaluateKeyboardEvent(event, false, false, false);
|
||||
assert.equal(result.key, '\x1b-', 'Alt+- should work normally without composition interference');
|
||||
});
|
||||
|
||||
describe('Browser integration considerations', () => {
|
||||
it('should document known scenarios where Alt+- might not work', () => {
|
||||
// This test documents potential causes for the reported issue
|
||||
// NOTE: These are integration issues, not keyboard evaluation issues
|
||||
|
||||
const possibleCauses = [
|
||||
'Terminal not properly focused when testing',
|
||||
'Custom key event handlers preventing default behavior',
|
||||
'IME or composition methods intercepting events',
|
||||
'Browser-specific keyboard layout differences',
|
||||
'Third-party browser extensions interfering'
|
||||
];
|
||||
|
||||
// The keyboard evaluation should always work correctly
|
||||
const event = createMockKeyboardEvent({
|
||||
altKey: true,
|
||||
keyCode: 189,
|
||||
key: '-',
|
||||
type: 'keydown'
|
||||
});
|
||||
|
||||
const result = evaluateKeyboardEvent(event, false, false, false);
|
||||
assert.equal(result.key, '\x1b-', 'Keyboard evaluation should always work');
|
||||
|
||||
// Document that integration issues are separate concerns
|
||||
assert.isTrue(possibleCauses.length > 0, 'There are known integration scenarios to consider');
|
||||
});
|
||||
|
||||
it('should handle different keyboard layouts gracefully', () => {
|
||||
// Some keyboard layouts might use different keyCodes for minus
|
||||
const potentialMinusKeyCodes = [189, 173, 109]; // Various minus key codes
|
||||
|
||||
potentialMinusKeyCodes.forEach(keyCode => {
|
||||
const event = createMockKeyboardEvent({
|
||||
altKey: true,
|
||||
keyCode: keyCode,
|
||||
key: '-',
|
||||
type: 'keydown'
|
||||
});
|
||||
|
||||
const result = evaluateKeyboardEvent(event, false, false, false);
|
||||
|
||||
if (keyCode === 189) {
|
||||
// Standard minus key should always work
|
||||
assert.equal(result.key, '\x1b-', `Standard minus (keyCode ${keyCode}) should work`);
|
||||
} else {
|
||||
// Other keys might not be mapped, but should not throw errors
|
||||
assert.isDefined(result, `KeyCode ${keyCode} should return a valid result`);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1280,10 +1280,10 @@ enum CellColorPosition {
|
||||
* treatment.
|
||||
*/
|
||||
export function injectSharedRendererTestsStandalone(ctx: ISharedRendererTestContext, setupCb: () => Promise<void> | void): void {
|
||||
test.describe('standalone tests', () => {
|
||||
const setupTests = ({ shadowDom }: { shadowDom: boolean }): void => {
|
||||
test.beforeEach(async () => {
|
||||
// Recreate terminal
|
||||
await openTerminal(ctx.value);
|
||||
await openTerminal(ctx.value, {}, { useShadowDom: shadowDom });
|
||||
await ctx.value.page.evaluate(`
|
||||
window.term.options.minimumContrastRatio = 1;
|
||||
window.term.options.allowTransparency = false;
|
||||
@@ -1308,6 +1308,13 @@ export function injectSharedRendererTestsStandalone(ctx: ISharedRendererTestCont
|
||||
await pollFor(ctx.value.page, () => getCellColor(ctx.value, 1, 1), [0, 0, 0, 255]);
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
test.describe('standalone tests', () => {
|
||||
setupTests({ shadowDom: false });
|
||||
});
|
||||
test.describe('standalone tests (Shadow dom)', () => {
|
||||
setupTests({ shadowDom: true });
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -412,7 +412,14 @@ class TerminalCoreProxy {
|
||||
}
|
||||
}
|
||||
|
||||
export async function openTerminal(ctx: ITestContext, options: ITerminalOptions | ITerminalInitOnlyOptions = {}, testOptions: { loadUnicodeGraphemesAddon: boolean } = { loadUnicodeGraphemesAddon: true }): Promise<void> {
|
||||
export async function openTerminal(
|
||||
ctx: ITestContext,
|
||||
options: ITerminalOptions | ITerminalInitOnlyOptions = {},
|
||||
testOptions: { useShadowDom?: boolean, loadUnicodeGraphemesAddon?: boolean } = {}
|
||||
): Promise<void> {
|
||||
testOptions.useShadowDom ??= false;
|
||||
testOptions.loadUnicodeGraphemesAddon ??= true;
|
||||
|
||||
await ctx.page.evaluate(`
|
||||
if ('term' in window) {
|
||||
try {
|
||||
@@ -423,10 +430,45 @@ export async function openTerminal(ctx: ITestContext, options: ITerminalOptions
|
||||
// HACK: Tests may have side effects that could cause the terminal not to be removed. This
|
||||
// assertion catches this case early.
|
||||
strictEqual(await ctx.page.evaluate(`document.querySelector('#terminal-container').children.length`), 0, 'there must be no terminals on the page');
|
||||
await ctx.page.evaluate(`
|
||||
|
||||
let script = `
|
||||
window.term = new window.Terminal(${JSON.stringify({ allowProposedApi: true, ...options })});
|
||||
window.term.open(document.querySelector('#terminal-container'));
|
||||
`);
|
||||
let element = document.querySelector('#terminal-container');
|
||||
|
||||
// Remove shadow root if it exists
|
||||
const newElement = element.cloneNode(false);
|
||||
element.replaceWith(newElement);
|
||||
element = newElement
|
||||
`;
|
||||
|
||||
|
||||
if (testOptions.useShadowDom) {
|
||||
script += `
|
||||
const shadowRoot = element.attachShadow({ mode: "open" });
|
||||
|
||||
// Copy parent styles to shadow DOM
|
||||
const styles = Array.from(document.querySelectorAll('link[rel="stylesheet"]'));
|
||||
styles.forEach((styleEl) => {
|
||||
const clone = document.createElement('link');
|
||||
clone.rel = 'stylesheet';
|
||||
clone.href = styleEl.href;
|
||||
shadowRoot.appendChild(clone);
|
||||
});
|
||||
|
||||
// Create new element inside the shadow DOM
|
||||
element = document.createElement('div');
|
||||
element.style.width = '100%';
|
||||
element.style.height = '100%';
|
||||
shadowRoot.appendChild(element);
|
||||
`;
|
||||
}
|
||||
|
||||
script += `
|
||||
window.term.open(element);
|
||||
`;
|
||||
|
||||
await ctx.page.evaluate(script);
|
||||
|
||||
// HACK: This is a soft layer breaker that's temporarily included until unicode graphemes have
|
||||
// more complete integration tests. See https://github.com/xtermjs/xterm.js/pull/4519#discussion_r1285234453
|
||||
if (testOptions.loadUnicodeGraphemesAddon) {
|
||||
|
||||
Reference in New Issue
Block a user