Revert the terminal IME composition-ownership change (#13282)
* Revert "test(ime): restore coverage the composition-ownership change removed (#13168)" This reverts commit25a8c517e1. * Revert "refactor(terminal): return IME composition ownership to xterm (#13128)" This reverts commit17b3dff3c4. * test(ime): keep the architecture-neutral Korean trace coverage The recorded IBus/fcitx5 and Windows MS-Korean traces from #13168 assert PTY byte order, not composition ownership, so they still hold once the terminal composition layer is restored. The mobile accessory-order test pinned the new handleLiveInputChange signature and does not. Co-authored-by: Orca <help@stably.ai> * fix(terminal): keep the macOS Backslash bypass through the revert The restored native-text forwarder only claims keys for input sources in its hardcoded CJK allowlist, so third-party IMEs off that list (Qingg, #10896) still get a raw backslash. #13128 added this bypass as a partial replacement; keep it rather than trade the open issue back. Scoped to the bare backslash key. The rest of shouldBypassXtermForMacNativeText bypassed all unmodified non-ASCII text, which would race the restored forwarder. Co-authored-by: Orca <help@stably.ai> * fix(mobile): move the mirror-step ref write out of render The restored hook assigned runMirrorStepRef during render, which is not replay-safe — React can discard render work, so the mutation can leak from UI that never commits. Its only read is inside the held-commit timer, which fires long after commit, and the ref has a safe default, so an effect is soon enough. Surfaced by the changed-lines React Doctor gate: the rule postdates this code, so restoring the file re-introduced it as a new violation. Co-authored-by: Orca <help@stably.ai> --------- Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
parent
17eefef502
commit
17cfc968cf
|
|
@ -12,8 +12,3 @@
|
|||
/src/cli/bundled-skill-guides.ts text eol=lf
|
||||
# Bundled plugin trees are byte-hashed; CRLF checkout would break the pinned hash.
|
||||
/resources/plugins/** text eol=lf
|
||||
# pnpm hashes this generated patch byte-for-byte, so a CRLF checkout breaks the install;
|
||||
# its minified bundle lines also make a diff nobody can read. Review the hand-written
|
||||
# source patch under xterm-src/ instead.
|
||||
/config/patches/@xterm__xterm@*.patch -diff -text
|
||||
/config/patches/xterm-src/*.patch text eol=lf
|
||||
|
|
|
|||
|
|
@ -173,34 +173,6 @@ jobs:
|
|||
done
|
||||
exit "$status"
|
||||
|
||||
xterm_patch_sync:
|
||||
name: xterm patch sync
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- uses: ./.github/actions/install-node-dependencies
|
||||
|
||||
# Why: the check rebuilds xterm.js from a pinned upstream commit. Caching the
|
||||
# npm metadata and the shallow clone turns a ~4 min cold run into well under a
|
||||
# minute; the key is the manifest, so a commit or toolchain bump invalidates it.
|
||||
- name: Restore upstream xterm build inputs
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
~/.npm
|
||||
${{ runner.temp }}/xterm-patch-build/upstream/.git
|
||||
key: xterm-upstream-${{ hashFiles('config/patches/xterm-upstream.json') }}
|
||||
|
||||
- name: Verify xterm patches match the pinned upstream build
|
||||
env:
|
||||
WORK_DIR: ${{ runner.temp }}/xterm-patch-build
|
||||
run: node config/scripts/regenerate-xterm-patches.mjs --check --work-dir="$WORK_DIR"
|
||||
|
||||
shell_contracts:
|
||||
name: shell contracts
|
||||
runs-on: ubuntu-latest
|
||||
|
|
@ -443,8 +415,7 @@ jobs:
|
|||
BASE="${{ github.event.pull_request.base.sha }}"
|
||||
HEAD="${{ github.event.pull_request.head.sha }}"
|
||||
CHANGED="$(git diff --name-only --diff-filter=AMCR --merge-base "$BASE" "$HEAD")"
|
||||
# Native specs require their dedicated platform harnesses.
|
||||
TEST_FILES="$(printf '%s\n' "$CHANGED" | grep -E '^tests/e2e/.*\.spec\.ts$' | grep -Ev -- '-native\.spec\.ts$' || true)"
|
||||
TEST_FILES="$(printf '%s\n' "$CHANGED" | grep -E '^tests/e2e/.*\.spec\.ts$' || true)"
|
||||
TEST_FILES_JSON="$(printf '%s\n' "$TEST_FILES" | jq --raw-input --slurp --compact-output 'split("\n") | map(select(length > 0))')"
|
||||
echo "test_files=$TEST_FILES_JSON" >> "$GITHUB_OUTPUT"
|
||||
if [ "$TEST_FILES_JSON" != '[]' ]; then
|
||||
|
|
@ -473,7 +444,6 @@ jobs:
|
|||
- root_directory_guard
|
||||
- typecheck
|
||||
- git_compatibility
|
||||
- xterm_patch_sync
|
||||
- shell_contracts
|
||||
- test
|
||||
- managed_hook_node18
|
||||
|
|
@ -496,7 +466,6 @@ jobs:
|
|||
ROOT_DIRECTORY_GUARD: ${{ needs.root_directory_guard.result }}
|
||||
TYPECHECK: ${{ needs.typecheck.result }}
|
||||
GIT_COMPATIBILITY: ${{ needs.git_compatibility.result }}
|
||||
XTERM_PATCH_SYNC: ${{ needs.xterm_patch_sync.result }}
|
||||
SHELL_CONTRACTS: ${{ needs.shell_contracts.result }}
|
||||
TEST: ${{ needs.test.result }}
|
||||
MANAGED_HOOK_NODE18: ${{ needs.managed_hook_node18.result }}
|
||||
|
|
@ -508,7 +477,6 @@ jobs:
|
|||
"$ROOT_DIRECTORY_GUARD" \
|
||||
"$TYPECHECK" \
|
||||
"$GIT_COMPATIBILITY" \
|
||||
"$XTERM_PATCH_SYNC" \
|
||||
"$SHELL_CONTRACTS" \
|
||||
"$TEST" \
|
||||
"$MANAGED_HOOK_NODE18" \
|
||||
|
|
|
|||
|
|
@ -9,20 +9,10 @@ permissions:
|
|||
contents: read
|
||||
|
||||
jobs:
|
||||
linux:
|
||||
name: Linux ${{ matrix.label }} terminal IME
|
||||
runs-on: ${{ matrix.os }}
|
||||
linux-x11:
|
||||
name: Linux X11 terminal IME
|
||||
runs-on: ubuntu-22.04
|
||||
timeout-minutes: 25
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- label: X11
|
||||
os: ubuntu-22.04
|
||||
display_server: x11
|
||||
- label: Wayland
|
||||
os: ubuntu-24.04
|
||||
display_server: wayland
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
|
|
@ -37,17 +27,10 @@ jobs:
|
|||
build-essential
|
||||
dbus-x11
|
||||
dconf-gsettings-backend
|
||||
fcitx5
|
||||
fcitx5-chinese-addons
|
||||
fcitx5-frontend-gtk3
|
||||
fcitx5-hangul
|
||||
ibus
|
||||
ibus-hangul
|
||||
ibus-libpinyin
|
||||
libglib2.0-bin
|
||||
python3
|
||||
sway
|
||||
wtype
|
||||
xdotool
|
||||
xfwm4
|
||||
xvfb
|
||||
|
|
@ -73,57 +56,7 @@ jobs:
|
|||
- name: Build Electron app for E2E
|
||||
run: pnpm exec electron-vite build --mode e2e
|
||||
|
||||
- name: Run native IBus exact-byte tests
|
||||
if: matrix.display_server == 'x11'
|
||||
env:
|
||||
ORCA_E2E_NATIVE_IME: ibus
|
||||
SKIP_BUILD: '1'
|
||||
run: pnpm run test:e2e:terminal-ime-native
|
||||
|
||||
- name: Upload native IBus evidence
|
||||
if: ${{ always() && matrix.display_server == 'x11' }}
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: terminal-ime-native-evidence
|
||||
path: test-results/
|
||||
retention-days: 7
|
||||
if-no-files-found: ignore
|
||||
|
||||
- name: Run native Fcitx5 exact-byte tests
|
||||
if: matrix.display_server == 'x11'
|
||||
env:
|
||||
ORCA_E2E_NATIVE_IME: fcitx5
|
||||
SKIP_BUILD: '1'
|
||||
run: pnpm run test:e2e:terminal-ime-native
|
||||
|
||||
- name: Upload native Fcitx5 evidence
|
||||
if: ${{ always() && matrix.display_server == 'x11' }}
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: terminal-ime-native-fcitx5-evidence
|
||||
path: test-results/
|
||||
retention-days: 7
|
||||
if-no-files-found: ignore
|
||||
|
||||
- name: Run native Fcitx5 Wayland exact-byte tests
|
||||
if: matrix.display_server == 'wayland'
|
||||
env:
|
||||
ORCA_E2E_NATIVE_DISPLAY_SERVER: wayland
|
||||
ORCA_E2E_NATIVE_IME: fcitx5
|
||||
SKIP_BUILD: '1'
|
||||
run: pnpm run test:e2e:terminal-ime-native
|
||||
|
||||
- name: Upload native Fcitx5 Wayland evidence
|
||||
if: ${{ always() && matrix.display_server == 'wayland' }}
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: terminal-ime-native-fcitx5-wayland-evidence
|
||||
path: test-results/
|
||||
retention-days: 7
|
||||
if-no-files-found: ignore
|
||||
|
||||
- name: Run deterministic terminal IME boundary tests
|
||||
if: matrix.display_server == 'x11'
|
||||
run: >-
|
||||
xvfb-run --auto-servernum
|
||||
env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1
|
||||
|
|
@ -131,8 +64,13 @@ jobs:
|
|||
tests/e2e/terminal-ime-exact-byte.spec.ts
|
||||
--workers=1
|
||||
|
||||
- name: Run native IBus Hangul exact-byte tests
|
||||
env:
|
||||
SKIP_BUILD: '1'
|
||||
run: pnpm run test:e2e:terminal-ime-native
|
||||
|
||||
- name: Upload terminal IME evidence
|
||||
if: ${{ always() && matrix.display_server == 'x11' }}
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: terminal-ime-evidence
|
||||
|
|
|
|||
|
|
@ -102,7 +102,6 @@ docs/**
|
|||
!docs/reference/relay-grace-time-reconfiguration.md
|
||||
!docs/reference/remote-wire-compatibility.md
|
||||
!docs/reference/windows-setup-shell.md
|
||||
!docs/reference/xterm-patch-regeneration.md
|
||||
|
||||
# Stably CLI (only docs/ are tracked)
|
||||
.stably/*
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
|
|
@ -1,363 +0,0 @@
|
|||
diff --git a/src/browser/CoreBrowserTerminal.ts b/src/browser/CoreBrowserTerminal.ts
|
||||
index 4557e1652c34737fdf853436bd9328d9918eee2b..c16096341dadfd215a8086e13f7a0551025c0e77 100644
|
||||
--- a/src/browser/CoreBrowserTerminal.ts
|
||||
+++ b/src/browser/CoreBrowserTerminal.ts
|
||||
@@ -735,6 +735,10 @@ export class CoreBrowserTerminal extends CoreTerminal implements ITerminal {
|
||||
paste(data, this.textarea!, this.coreService, this.optionsService);
|
||||
}
|
||||
|
||||
+ public override input(data: string, wasUserInput: boolean = true): void {
|
||||
+ if (!wasUserInput || !this._compositionHelper?.handleCompositionInput(data, false)) super.input(data, wasUserInput);
|
||||
+ }
|
||||
+
|
||||
public attachCustomKeyEventHandler(customKeyEventHandler: CustomKeyEventHandler): void {
|
||||
this._customKeyEventHandler = customKeyEventHandler;
|
||||
}
|
||||
@@ -1029,6 +1033,7 @@ export class CoreBrowserTerminal extends CoreTerminal implements ITerminal {
|
||||
// Only support emoji IMEs when screen reader mode is disabled as the event must bubble up to
|
||||
// support reading out character input which can doubling up input characters
|
||||
// Based on these event traces: https://github.com/xtermjs/xterm.js/issues/3679
|
||||
+ if (ev.data && ev.inputType === 'insertText' && this._compositionHelper?.handleCompositionInput(ev.data, true)) return true;
|
||||
if (ev.data && ev.inputType === 'insertText' && (!ev.composed || !this._keyDownSeen) && !this.optionsService.rawOptions.screenReaderMode) {
|
||||
if (this._keyPressHandled) {
|
||||
return false;
|
||||
diff --git a/src/browser/TestUtils.test.ts b/src/browser/TestUtils.test.ts
|
||||
index 7b346459521dd77f9de25d43fba3c36a6f8459b2..4837a7c1a3eb36ec6f6223cbcec0f2dd6cc4e235 100644
|
||||
--- a/src/browser/TestUtils.test.ts
|
||||
+++ b/src/browser/TestUtils.test.ts
|
||||
@@ -342,6 +342,9 @@ export class MockViewport implements IViewport {
|
||||
}
|
||||
|
||||
export class MockCompositionHelper implements ICompositionHelper {
|
||||
+ public handleCompositionInput(data: string, nativeCommit: boolean): boolean {
|
||||
+ throw new Error('Method not implemented.');
|
||||
+ }
|
||||
public get isComposing(): boolean {
|
||||
return false;
|
||||
}
|
||||
diff --git a/src/browser/Types.ts b/src/browser/Types.ts
|
||||
index 497afcf535f3eaca00889525a77e15eb633ccd96..e3cad77734795f6cf34bb7120264fe81070941ed 100644
|
||||
--- a/src/browser/Types.ts
|
||||
+++ b/src/browser/Types.ts
|
||||
@@ -39,6 +39,7 @@ export type LineData = CharData[];
|
||||
|
||||
export interface ICompositionHelper {
|
||||
readonly isComposing: boolean;
|
||||
+ handleCompositionInput(data: string, nativeCommit: boolean): boolean;
|
||||
compositionstart(): void;
|
||||
compositionupdate(ev: CompositionEvent): void;
|
||||
compositionend(): void;
|
||||
diff --git a/src/browser/input/CompositionHelper.test.ts b/src/browser/input/CompositionHelper.test.ts
|
||||
index 5a1e6c38c9799f7f57d5df6d4a122a7beb0cf04f..2d78e414177804261acbbe2f53d71b5c8709ceb6 100644
|
||||
--- a/src/browser/input/CompositionHelper.test.ts
|
||||
+++ b/src/browser/input/CompositionHelper.test.ts
|
||||
@@ -6,7 +6,7 @@
|
||||
import { assert } from 'chai';
|
||||
import { CompositionHelper } from './CompositionHelper';
|
||||
import { MockRenderService } from '../TestUtils.test';
|
||||
-import { MockCoreService, MockBufferService, MockOptionsService } from '../../common/TestUtils.test';
|
||||
+import { MockCoreService, MockBufferService, MockOptionsService, MockUnicodeService } from '../../common/TestUtils.test';
|
||||
|
||||
describe('CompositionHelper', () => {
|
||||
let compositionHelper: CompositionHelper;
|
||||
@@ -42,7 +42,7 @@ describe('CompositionHelper', () => {
|
||||
};
|
||||
handledText = '';
|
||||
const bufferService = new MockBufferService(10, 5);
|
||||
- compositionHelper = new CompositionHelper(textarea, compositionView, bufferService, new MockOptionsService(), coreService, new MockRenderService());
|
||||
+ compositionHelper = new CompositionHelper(textarea, compositionView, bufferService, new MockOptionsService(), coreService, new MockRenderService(), new MockUnicodeService());
|
||||
});
|
||||
|
||||
describe('Input', () => {
|
||||
diff --git a/src/browser/input/CompositionHelper.ts b/src/browser/input/CompositionHelper.ts
|
||||
index c9ec396ab66cb966d49aa63bed09cdf9cd6c4246..ea77d070322479e75201970d63fc41336d64dc93 100644
|
||||
--- a/src/browser/input/CompositionHelper.ts
|
||||
+++ b/src/browser/input/CompositionHelper.ts
|
||||
@@ -4,8 +4,7 @@
|
||||
*/
|
||||
|
||||
import { IRenderService } from '../services/Services';
|
||||
-import { IBufferService, ICoreService, IOptionsService } from '../../common/services/Services';
|
||||
-import { C0 } from '../../common/data/EscapeSequences';
|
||||
+import { IBufferService, ICoreService, IOptionsService, IUnicodeService } from '../../common/services/Services';
|
||||
|
||||
interface IPosition {
|
||||
start: number;
|
||||
@@ -42,15 +41,12 @@ export class CompositionHelper {
|
||||
*/
|
||||
private _isSendingComposition: boolean;
|
||||
|
||||
- /**
|
||||
- * Data already sent due to keydown event.
|
||||
- */
|
||||
- private _dataAlreadySent: string;
|
||||
+ private _pendingCompositionStart?: number;
|
||||
+ private _pendingInput = '';
|
||||
+ private _sentComposition = '';
|
||||
|
||||
- /**
|
||||
- * The pending textarea change timer, if any.
|
||||
- */
|
||||
- private _textareaChangeTimer?: number;
|
||||
+ /** Text and cell width the current letter-spacing was measured for. */
|
||||
+ private _gridAdvanceKey = '';
|
||||
|
||||
constructor(
|
||||
private readonly _textarea: HTMLTextAreaElement,
|
||||
@@ -58,13 +54,13 @@ export class CompositionHelper {
|
||||
@IBufferService private readonly _bufferService: IBufferService,
|
||||
@IOptionsService private readonly _optionsService: IOptionsService,
|
||||
@ICoreService private readonly _coreService: ICoreService,
|
||||
- @IRenderService private readonly _renderService: IRenderService
|
||||
+ @IRenderService private readonly _renderService: IRenderService,
|
||||
+ @IUnicodeService private readonly _unicodeService: IUnicodeService
|
||||
) {
|
||||
this._isComposing = false;
|
||||
this._isSendingComposition = false;
|
||||
this._compositionPosition = { start: 0, end: 0 };
|
||||
this._compositionSuffix = '';
|
||||
- this._dataAlreadySent = '';
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -80,10 +76,27 @@ export class CompositionHelper {
|
||||
this._compositionPosition.end = Math.max(start, end);
|
||||
this._compositionSuffix = this._textarea.value.substring(this._compositionPosition.end);
|
||||
this._compositionView.textContent = '';
|
||||
- this._dataAlreadySent = '';
|
||||
this._compositionView.classList.add('active');
|
||||
}
|
||||
|
||||
+ public handleCompositionInput(data: string, nativeCommit: boolean): boolean {
|
||||
+ if (nativeCommit) {
|
||||
+ if (!this._isSendingComposition) return false;
|
||||
+ // Once the deferred send has run, an IME that delivers its commit a task late (IBus) repeats
|
||||
+ // text we already sent; anything else is new input and must not be discarded with it.
|
||||
+ const alreadySent = this._pendingCompositionStart === undefined && data === this._sentComposition;
|
||||
+ const input = (alreadySent ? '' : data) + this._pendingInput;
|
||||
+ this._pendingCompositionStart = undefined;
|
||||
+ this._pendingInput = '';
|
||||
+ if (input.length > 0) this._coreService.triggerDataEvent(input, true);
|
||||
+ this._isSendingComposition = false;
|
||||
+ return true;
|
||||
+ }
|
||||
+ if (!this._isComposing && !this._isSendingComposition) return false;
|
||||
+ this._pendingInput += data;
|
||||
+ return true;
|
||||
+ }
|
||||
+
|
||||
/**
|
||||
* Handles the compositionupdate event, updating the composition view.
|
||||
* @param ev The event.
|
||||
@@ -119,8 +132,10 @@ export class CompositionHelper {
|
||||
// Continue composing if the keyCode is the "composition character"
|
||||
return false;
|
||||
}
|
||||
- if (ev.keyCode === 16 || ev.keyCode === 17 || ev.keyCode === 18) {
|
||||
- // Continue composing if the keyCode is a modifier key
|
||||
+ if (ev.keyCode === 16 || ev.keyCode === 17 || ev.keyCode === 18 || ev.keyCode === 91 || ev.keyCode === 93 || ev.keyCode === 224) {
|
||||
+ // Continue composing if the keyCode is a modifier key. 91/93 are Chromium's left and
|
||||
+ // right Meta, 224 is Firefox's. Only a lone modifier press reports these, and on macOS
|
||||
+ // finalizing there hides the preedit for the rest of a word the IME is still composing.
|
||||
return false;
|
||||
}
|
||||
// Finish composition immediately. This is mainly here for the case where enter is
|
||||
@@ -129,9 +144,6 @@ export class CompositionHelper {
|
||||
}
|
||||
|
||||
if (ev.keyCode === 229) {
|
||||
- // If the "composition character" is used but gets to this point it means a non-composition
|
||||
- // character (eg. numbers and punctuation) was pressed when the IME was active.
|
||||
- this._handleAnyTextareaChanges();
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -153,7 +165,11 @@ export class CompositionHelper {
|
||||
if (!waitForPropagation) {
|
||||
// Cancel any delayed composition send requests and send the input immediately.
|
||||
this._isSendingComposition = false;
|
||||
- const input = this._textarea.value.substring(this._compositionPosition.start, this._compositionPosition.end);
|
||||
+ const start = this._pendingCompositionStart ?? this._compositionPosition.start;
|
||||
+ const end = Math.max(start, this._textarea.selectionEnd ?? this._compositionPosition.end);
|
||||
+ this._pendingCompositionStart = undefined;
|
||||
+ const input = this._textarea.value.substring(start, end) + this._pendingInput;
|
||||
+ this._pendingInput = '';
|
||||
this._coreService.triggerDataEvent(input, true);
|
||||
} else {
|
||||
// Make a deep copy of the composition position here as a new compositionstart event may
|
||||
@@ -163,6 +179,7 @@ export class CompositionHelper {
|
||||
end: this._compositionPosition.end
|
||||
};
|
||||
const currentCompositionSuffix = this._compositionSuffix;
|
||||
+ this._pendingCompositionStart ??= currentCompositionPosition.start;
|
||||
|
||||
// Since composition* events happen before the changes take place in the textarea on most
|
||||
// browsers, use a setTimeout with 0ms time to allow the native compositionend event to
|
||||
@@ -175,12 +192,10 @@ export class CompositionHelper {
|
||||
this._isSendingComposition = true;
|
||||
setTimeout(() => {
|
||||
// Ensure that the input has not already been sent
|
||||
- if (this._isSendingComposition) {
|
||||
- this._isSendingComposition = false;
|
||||
+ if (this._isSendingComposition && this._pendingCompositionStart !== undefined) {
|
||||
+ currentCompositionPosition.start = this._pendingCompositionStart;
|
||||
+ this._pendingCompositionStart = undefined;
|
||||
let input;
|
||||
- // Add length of data already sent due to keydown event,
|
||||
- // otherwise input characters can be duplicated. (Issue #3191)
|
||||
- currentCompositionPosition.start += this._dataAlreadySent.length;
|
||||
if (this._isComposing) {
|
||||
// Use the start position of the new composition to get the string
|
||||
// if a new composition has started.
|
||||
@@ -195,47 +210,22 @@ export class CompositionHelper {
|
||||
: value.length;
|
||||
input = value.substring(currentCompositionPosition.start, Math.max(currentCompositionPosition.start, valueEnd));
|
||||
}
|
||||
- if (input.length > 0) {
|
||||
- this._coreService.triggerDataEvent(input, true);
|
||||
- }
|
||||
+ this._sentComposition = input;
|
||||
+ input += this._pendingInput;
|
||||
+ this._pendingInput = '';
|
||||
+ if (input.length > 0) this._coreService.triggerDataEvent(input, true);
|
||||
+ setTimeout(() => {
|
||||
+ if (this._pendingCompositionStart === undefined) {
|
||||
+ if (this._pendingInput.length > 0) this._coreService.triggerDataEvent(this._pendingInput, true);
|
||||
+ this._pendingInput = '';
|
||||
+ this._isSendingComposition = false;
|
||||
+ }
|
||||
+ }, 0);
|
||||
}
|
||||
}, 0);
|
||||
}
|
||||
}
|
||||
|
||||
- /**
|
||||
- * Apply any changes made to the textarea after the current event chain is allowed to complete.
|
||||
- * This should be called when not currently composing but a keydown event with the "composition
|
||||
- * character" (229) is triggered, in order to allow non-composition text to be entered when an
|
||||
- * IME is active.
|
||||
- */
|
||||
- private _handleAnyTextareaChanges(): void {
|
||||
- if (this._textareaChangeTimer) {
|
||||
- return;
|
||||
- }
|
||||
- const oldValue = this._textarea.value;
|
||||
- this._textareaChangeTimer = window.setTimeout(() => {
|
||||
- this._textareaChangeTimer = undefined;
|
||||
- // Ignore if a composition has started since the timeout
|
||||
- if (!this._isComposing) {
|
||||
- const newValue = this._textarea.value;
|
||||
-
|
||||
- const diff = newValue.replace(oldValue, '');
|
||||
-
|
||||
- this._dataAlreadySent = diff;
|
||||
-
|
||||
- if (newValue.length > oldValue.length) {
|
||||
- this._coreService.triggerDataEvent(diff, true);
|
||||
- } else if (newValue.length < oldValue.length) {
|
||||
- this._coreService.triggerDataEvent(`${C0.DEL}`, true);
|
||||
- } else if ((newValue.length === oldValue.length) && (newValue !== oldValue)) {
|
||||
- this._coreService.triggerDataEvent(newValue, true);
|
||||
- }
|
||||
-
|
||||
- }
|
||||
- }, 0);
|
||||
- }
|
||||
-
|
||||
/**
|
||||
* Positions the composition view on top of the cursor and the textarea just below it (so the
|
||||
* IME helper dialog is positioned correctly).
|
||||
@@ -260,6 +250,7 @@ export class CompositionHelper {
|
||||
this._compositionView.style.lineHeight = cellHeight + 'px';
|
||||
this._compositionView.style.fontFamily = this._optionsService.rawOptions.fontFamily;
|
||||
this._compositionView.style.fontSize = this._optionsService.rawOptions.fontSize + 'px';
|
||||
+ this._alignPreeditToGrid(this._renderService.dimensions.css.cell.width);
|
||||
// Limit the composition view width to the space between the cursor and
|
||||
// the terminal's right edge, preventing it from overflowing the terminal.
|
||||
const maxWidth = this._bufferService.cols * this._renderService.dimensions.css.cell.width - cursorLeft;
|
||||
@@ -281,4 +272,39 @@ export class CompositionHelper {
|
||||
setTimeout(() => this.updateCompositionElements(true), 0);
|
||||
}
|
||||
}
|
||||
+
|
||||
+ /**
|
||||
+ * The overlay is laid out as plain text, so its extent is whatever advance the font
|
||||
+ * gives the preedit. For Hangul and CJK that is well under the cells the same text
|
||||
+ * occupies once committed (measured on macOS/SF Mono: 0.70 for Hangul, 0.81 for CJK,
|
||||
+ * against 1.00 for Latin), and the shortfall accumulates across the composition.
|
||||
+ * Spread it as letter-spacing, which is how DomRendererRowFactory lands committed
|
||||
+ * glyphs on the cell grid, so the preedit covers the cells it is about to become.
|
||||
+ */
|
||||
+ private _alignPreeditToGrid(cellWidth: number): void {
|
||||
+ const text = this._compositionView.textContent ?? '';
|
||||
+ // Runs on every render frame while composing; the measurement below forces layout.
|
||||
+ const key = `${cellWidth} | ||||