Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions src/browser/CoreBrowserTerminal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -952,6 +952,8 @@ export class CoreBrowserTerminal extends CoreTerminal implements ITerminal {
return;
}

this._compositionHelper?.keyup(ev);

if (!wasModifierKeyOnlyEvent(ev)) {
this.focus();
}
Expand Down
86 changes: 84 additions & 2 deletions src/browser/Terminal.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,13 @@
* @license MIT
*/

import { MockCompositionHelper, MockRenderer, MockViewport, TestTerminal } from 'browser/TestUtils.test';
import { CompositionHelper } from 'browser/input/CompositionHelper';
import { MockCompositionHelper, MockRenderer, MockRenderService, MockViewport, TestTerminal } from 'browser/TestUtils.test';
import type { IBrowser } from 'browser/Types';
import { assert } from 'chai';
import { DEFAULT_ATTR_DATA } from 'common/buffer/BufferLine';
import { CellData } from 'common/buffer/CellData';
import { MockUnicodeService, createCellData } from 'common/TestUtils.test';
import { MockBufferService, MockOptionsService, MockUnicodeService, createCellData } from 'common/TestUtils.test';
import { IMarker } from 'common/Types';

const INIT_COLS = 80;
Expand Down Expand Up @@ -175,6 +176,87 @@ describe('Terminal', () => {
});
});

describe('keyup handling', () => {
const create229KeyboardEvent = (type: 'keydown' | 'keyup'): KeyboardEvent => ({
type,
key: '。',
keyCode: 229
} as KeyboardEvent);

const createCompositionHelperStub = (onKeyup: () => void): MockCompositionHelper => {
const compositionHelper = new MockCompositionHelper();
compositionHelper.keyup = () => onKeyup();
return compositionHelper;
};

const setupRealCompositionHelper = (): HTMLTextAreaElement => {
const textarea = {
value: '',
focus: () => {},
blur: () => {},
style: {
left: 0,
top: 0
}
} as any as HTMLTextAreaElement;
const compositionView = {
classList: {
add: () => {},
remove: () => {}
},
getBoundingClientRect: () => ({ width: 0, height: 0 }),
style: {
left: 0,
top: 0
},
textContent: ''
} as any;
(term as any).textarea = textarea;
(term as any)._compositionHelper = new CompositionHelper(
textarea,
compositionView,
new MockBufferService(10, 5),
new MockOptionsService(),
term.coreService as any,
new MockRenderService()
);
return textarea;
};

it('should forward keyup event to composition helper', () => {
let keyupCalls = 0;
(term as any)._compositionHelper = createCompositionHelperStub(() => keyupCalls++);

term.keyUp(create229KeyboardEvent('keyup'));

assert.equal(keyupCalls, 1);
});

it('should not forward keyup event when custom keyup handler returns false', () => {
let keyupCalls = 0;
(term as any)._compositionHelper = createCompositionHelperStub(() => keyupCalls++);
term.attachCustomKeyEventHandler(ev => ev.type !== 'keyup');

term.keyUp(create229KeyboardEvent('keyup'));

assert.equal(keyupCalls, 0);
});

it('should emit pending keyCode 229 input on keyup when key matches', () => {
const calls: string[] = [];
(term.coreService as any).triggerDataEvent = (data: string) => calls.push(data);
const textarea = setupRealCompositionHelper();

term.keyDown(create229KeyboardEvent('keydown'));
assert.deepEqual(calls, []);

textarea.value = '。';
term.keyUp(create229KeyboardEvent('keyup'));

assert.deepEqual(calls, ['。']);
});
});

describe('clear', () => {
it('should clear a buffer equal to rows', () => {
const promptLine = term.buffer.lines.get(term.buffer.ybase + term.buffer.y);
Expand Down
3 changes: 3 additions & 0 deletions src/browser/TestUtils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import { Emitter, type IEvent } from 'common/Event';
export class TestTerminal extends CoreBrowserTerminal {
public get curAttrData(): IAttributeData { return (this as any)._inputHandler._curAttrData; }
public keyDown(ev: any): boolean | undefined { return this._keyDown(ev); }
public keyUp(ev: any): void { this._keyUp(ev); }
public keyPress(ev: any): boolean { return this._keyPress(ev); }
public writeP(data: string | Uint8Array): Promise<void> {
return new Promise(r => this.write(data, r));
Expand Down Expand Up @@ -359,6 +360,8 @@ export class MockCompositionHelper implements ICompositionHelper {
public keydown(ev: KeyboardEvent): boolean {
return true;
}
public keyup(ev: KeyboardEvent): void {
}
}

export class MockCoreBrowserService implements ICoreBrowserService {
Expand Down
1 change: 1 addition & 0 deletions src/browser/Types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ export interface ICompositionHelper {
compositionend(): void;
updateCompositionElements(dontRecurse?: boolean): void;
keydown(ev: KeyboardEvent): boolean;
keyup(ev: KeyboardEvent): void;
}

export interface IBrowser {
Expand Down
218 changes: 218 additions & 0 deletions src/browser/input/CompositionHelper.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,28 @@
import { assert } from 'chai';
import { CompositionHelper } from 'browser/input/CompositionHelper';
import { MockRenderService } from 'browser/TestUtils.test';
import { C0 } from 'common/data/EscapeSequences';
import { MockCoreService, MockBufferService, MockOptionsService } from 'common/TestUtils.test';

describe('CompositionHelper', () => {
let compositionHelper: CompositionHelper;
let compositionView: HTMLElement;
let textarea: HTMLTextAreaElement;
let handledText: string;
const nextTick = (callback: () => void): void => {
setTimeout(callback, 0);
};
const keydown229 = (key: string): boolean => compositionHelper.keydown({ keyCode: 229, key } as KeyboardEvent);
const keyup229 = (key: string): void => compositionHelper.keyup({ keyCode: 229, key } as KeyboardEvent);
const startPending229 = (oldValue: string, key = 'x'): void => {
textarea.value = oldValue;
assert.equal(keydown229(key), false);
};
const applyPending229Change = (oldValue: string, newValue: string, key = 'x'): void => {
startPending229(oldValue, key);
textarea.value = newValue;
keyup229(key);
};

beforeEach(() => {
compositionView = {
Expand Down Expand Up @@ -259,5 +274,208 @@ describe('CompositionHelper', () => {
}, 0);
}, 0);
});

it('Should handle keyCode 229 on keyup when key matches', () => {
textarea.value = '';
assert.equal(keydown229('。'), false);
textarea.value = '。';

keyup229('。');

assert.equal(handledText, '。');
});

it('Should allow keyup with a different key value and still emit from keydown path', (done) => {
textarea.value = '';
assert.equal(keydown229('。'), false);
textarea.value = '。';

keyup229('x');

nextTick(() => {
assert.equal(handledText, '。');
done();
});
});

it('Should emit precise DEL+insert on keyup for equal-length replacements in pending keyCode 229 path', (done) => {
applyPending229Change('ab', 'ac');

assert.equal(handledText, `${C0.DEL}c`);
nextTick(() => {
done();
});
});

it('Should emit precise DEL+insert on timer for equal-length replacements in pending keyCode 229 path', (done) => {
startPending229('ab');
textarea.value = 'ac';

nextTick(() => {
assert.equal(handledText, `${C0.DEL}c`);
done();
});
});

it('Should emit prefix-based DEL+insert for mid-string replacement', () => {
applyPending229Change('abcde', 'abXYde');

assert.equal(handledText, `${C0.DEL.repeat(3)}XYde`);
});

it('Should emit only inserted text for append-only changes', () => {
applyPending229Change('ab', 'abXYZ');

assert.equal(handledText, 'XYZ');
});

it('Should emit single DEL and cache new value on shrink', () => {
applyPending229Change('abc', 'a');

assert.equal(handledText, `${C0.DEL}`);
assert.equal((compositionHelper as any)._dataAlreadySent, 'a');
});

it('Should not emit when baseline and textarea value are unchanged', (done) => {
applyPending229Change('same', 'same');
assert.equal(handledText, '');
nextTick(() => {
assert.equal(handledText, '');
done();
});
});

it('Should emit pending keyCode 229 data from earliest baseline', (done) => {
textarea.value = 'x';
assert.equal(keydown229('。'), false);
textarea.value = 'xy';
assert.equal(keydown229(','), false);
textarea.value = 'xy,';

keyup229(',');

nextTick(() => {
assert.equal(handledText, 'y,');
done();
});
});

it('Should create only one timer for repeated keyCode 229 keydown', () => {
const originalSetTimeout = globalThis.setTimeout;
let scheduled = 0;
(globalThis as any).setTimeout = () => {
scheduled++;
return 1;
};
try {
textarea.value = '';
assert.equal(keydown229('。'), false);
assert.equal(keydown229(','), false);
assert.equal(keydown229('!'), false);
assert.equal(scheduled, 1);
} finally {
(globalThis as any).setTimeout = originalSetTimeout;
}
});

it('Should start a timer when keyCode 229 baseline exists but timer is missing', (done) => {
textarea.value = 'x';
assert.equal(keydown229('。'), false);

nextTick(() => {
assert.equal((compositionHelper as any)._pending229Baseline, 'x');
assert.equal((compositionHelper as any)._textareaChangeTimer, undefined);
assert.equal((compositionHelper as any)._pending229TimerFired, true);

assert.equal(keydown229(','), false);
assert.notEqual((compositionHelper as any)._textareaChangeTimer, undefined);
assert.equal((compositionHelper as any)._pending229TimerFired, false);

keyup229(',');
nextTick(() => {
assert.equal((compositionHelper as any)._pending229Baseline, undefined);
done();
});
});
});

it('Should clear pending baseline after timer and keyup fire without data', (done) => {
textarea.value = 'x';
assert.equal(keydown229('。'), false);

nextTick(() => {
keyup229('。');
textarea.value = 'xyz';
assert.equal(keydown229(','), false);
textarea.value = 'xyz,';
keyup229(',');

assert.equal(handledText, ',');
done();
});
});

it('Should allow keyup fallback when keydown 229 key is non-printable', (done) => {
textarea.value = '';
assert.equal(keydown229('Process'), false);

nextTick(() => {
textarea.value = '。';
keyup229('Unidentified');
assert.equal(handledText, '。');
done();
});
});

it('Should not emit pending keyCode 229 data after compositionstart', (done) => {
textarea.value = '';
assert.equal(keydown229('。'), false);
textarea.value = '。';

compositionHelper.compositionstart();

nextTick(() => {
assert.equal(handledText, '');
done();
});
});

it('Should cancel pending keyCode 229 keydown send on compositionend finalize after composition data is sent', (done) => {
textarea.value = '';
assert.equal(keydown229('。'), false);
nextTick(() => {
compositionHelper.compositionstart();
compositionHelper.compositionupdate({ data: 'x' });
textarea.value = 'x';
compositionHelper.compositionend();

nextTick(() => {
assert.equal(handledText, 'x');
assert.equal((compositionHelper as any)._pending229Baseline, undefined);
keyup229('。');
assert.equal(handledText, 'x');
done();
});
});
});

it('Should keep pending keyCode 229 keydown send on compositionend finalize when no composition data is sent', (done) => {
textarea.value = '';
assert.equal(keydown229('。'), false);
nextTick(() => {
compositionHelper.compositionstart();
compositionHelper.compositionupdate({ data: 'x' });
compositionHelper.compositionend();

nextTick(() => {
assert.equal(handledText, '');
assert.equal((compositionHelper as any)._pending229Baseline, '');
textarea.value = '。';
keyup229('。');
assert.equal(handledText, '。');
done();
});
});
});
});
});
Loading