fix(xterm): size the preedit overlay to the cells its text will occupy

updateCompositionElements computed the overlay's left edge from the grid
but never its width, so the preedit rendered at the font's natural advance
while the committed text takes two cells per wide glyph. Measured in
Chromium 150: 가나다라 drew 48.45px as a preedit and 69.20px once committed
— the same characters, same font, 30% narrower, and drifting further with
each syllable. Every macOS mono font carrying Hangul measured 0.49–0.72 of
two cells; never 1.0.

Deriving the width from wcwidth and the cell measure moves Korean, Japanese
and Chinese to 1.000 and leaves ASCII at 1.000, which it already was:

  한        12.125 -> 17.297   (17.30 expected)
  가나다라   48.453 -> 69.188   (69.20)
  안녕하세요 60.563 -> 86.500   (86.50)
  日本語     42.000 -> 51.906   (51.90)
  abcdefgh  69.234 -> 69.203   (69.20, unchanged)

Edited in config/patches/xterm-src/ and regenerated through the harness, so
the emitted patch and lockfile hash are derived rather than hand-written.

The unit test asserts the arithmetic, which is what CI can run. The pixel
consequence was measured on macOS with SF Mono in an Electron harness, not
on the Windows font stack STA-3232 reports from — so this demonstrates the
mechanism and does not stand as that row's platform evidence.
This commit is contained in:
Neil
2026-08-06 11:30:19 -07:00
parent 2cfa893b00
commit e04e0c88da
4 changed files with 430 additions and 65 deletions
File diff suppressed because one or more lines are too long
@@ -47,19 +47,43 @@ index 497afcf535f3eaca00889525a77e15eb633ccd96..e3cad77734795f6cf34bb7120264fe81
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..d5c532339da18e0fecf6193ad579839bae269b83 100644
index c9ec396ab66cb966d49aa63bed09cdf9cd6c4246..67188a5c3310ac694bde8112f5387eb712227358 100644
--- a/src/browser/input/CompositionHelper.ts
+++ b/src/browser/input/CompositionHelper.ts
@@ -5,7 +5,6 @@
@@ -4,8 +4,7 @@
*/
import { IRenderService } from '../services/Services';
import { IBufferService, ICoreService, IOptionsService } from '../../common/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,9 @@ export class CompositionHelper {
@@ -42,15 +41,12 @@ export class CompositionHelper {
*/
private _isSendingComposition: boolean;
@@ -67,18 +91,28 @@ index c9ec396ab66cb966d49aa63bed09cdf9cd6c4246..d5c532339da18e0fecf6193ad579839b
- * Data already sent due to keydown event.
- */
- private _dataAlreadySent: string;
-
- /**
- * The pending textarea change timer, if any.
- */
- private _textareaChangeTimer?: number;
+ 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,
@@ -64,7 +57,6 @@ export class CompositionHelper {
@@ -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 = '';
@@ -86,7 +120,7 @@ index c9ec396ab66cb966d49aa63bed09cdf9cd6c4246..d5c532339da18e0fecf6193ad579839b
}
/**
@@ -80,10 +72,27 @@ export class CompositionHelper {
@@ -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 = '';
@@ -115,7 +149,7 @@ index c9ec396ab66cb966d49aa63bed09cdf9cd6c4246..d5c532339da18e0fecf6193ad579839b
/**
* Handles the compositionupdate event, updating the composition view.
* @param ev The event.
@@ -129,9 +138,6 @@ export class CompositionHelper {
@@ -129,9 +142,6 @@ export class CompositionHelper {
}
if (ev.keyCode === 229) {
@@ -125,7 +159,7 @@ index c9ec396ab66cb966d49aa63bed09cdf9cd6c4246..d5c532339da18e0fecf6193ad579839b
return false;
}
@@ -153,7 +159,11 @@ export class CompositionHelper {
@@ -153,7 +163,11 @@ export class CompositionHelper {
if (!waitForPropagation) {
// Cancel any delayed composition send requests and send the input immediately.
this._isSendingComposition = false;
@@ -138,7 +172,7 @@ index c9ec396ab66cb966d49aa63bed09cdf9cd6c4246..d5c532339da18e0fecf6193ad579839b
this._coreService.triggerDataEvent(input, true);
} else {
// Make a deep copy of the composition position here as a new compositionstart event may
@@ -163,6 +173,7 @@ export class CompositionHelper {
@@ -163,6 +177,7 @@ export class CompositionHelper {
end: this._compositionPosition.end
};
const currentCompositionSuffix = this._compositionSuffix;
@@ -146,7 +180,7 @@ index c9ec396ab66cb966d49aa63bed09cdf9cd6c4246..d5c532339da18e0fecf6193ad579839b
// 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 +186,10 @@ export class CompositionHelper {
@@ -175,12 +190,10 @@ export class CompositionHelper {
this._isSendingComposition = true;
setTimeout(() => {
// Ensure that the input has not already been sent
@@ -162,7 +196,7 @@ index c9ec396ab66cb966d49aa63bed09cdf9cd6c4246..d5c532339da18e0fecf6193ad579839b
if (this._isComposing) {
// Use the start position of the new composition to get the string
// if a new composition has started.
@@ -195,47 +204,22 @@ export class CompositionHelper {
@@ -195,47 +208,22 @@ export class CompositionHelper {
: value.length;
input = value.substring(currentCompositionPosition.start, Math.max(currentCompositionPosition.start, valueEnd));
}
@@ -221,6 +255,54 @@ index c9ec396ab66cb966d49aa63bed09cdf9cd6c4246..d5c532339da18e0fecf6193ad579839b
/**
* 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 +248,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 +270,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}${text}`;
+ if (key === this._gridAdvanceKey) {
+ return;
+ }
+ this._gridAdvanceKey = key;
+ // Letter-spacing lands after each character that advances, so the LTR marks
+ // wrapping the preedit are not among the gaps the shortfall is divided over.
+ let advancing = 0;
+ for (const character of text) {
+ if (this._unicodeService.wcwidth(character.codePointAt(0)!) > 0) {
+ advancing++;
+ }
+ }
+ this._compositionView.style.letterSpacing = '';
+ if (advancing === 0) {
+ return;
+ }
+ // Measured with maxWidth cleared: a long preedit's natural advance exceeds it.
+ this._compositionView.style.maxWidth = '';
+ const naturalWidth = this._compositionView.getBoundingClientRect().width;
+ const gridWidth = this._unicodeService.getStringCellWidth(text) * cellWidth;
+ this._compositionView.style.letterSpacing = `${(gridWidth - naturalWidth) / advancing}px`;
+ }
}
diff --git a/src/common/SortedList.ts b/src/common/SortedList.ts
index 8a10076e3963e33b4a7d1e4602333eb3f4772dc9..df0761c35907ddc48eb102ba181b0dac8e61f00d 100644
--- a/src/common/SortedList.ts
@@ -250,3 +332,19 @@ index 8a10076e3963e33b4a7d1e4602333eb3f4772dc9..df0761c35907ddc48eb102ba181b0dac
i = this._search(key);
if (i === -1) {
return false;
diff --git a/src/common/TestUtils.test.ts b/src/common/TestUtils.test.ts
index bedfa6ba1fdd6fb24646f52167e4881dc03498be..5d87be2d5cc52cb4504fd383243c2de72290bd04 100644
--- a/src/common/TestUtils.test.ts
+++ b/src/common/TestUtils.test.ts
@@ -225,8 +225,10 @@ export class MockUnicodeService implements IUnicodeService {
}
return UnicodeService.createPropertyValue(0, width, shouldJoin);
}
+ // Reuses the real traversal against this mock's own provider; CompositionHelper
+ // sizes the preedit overlay with it, so throwing here would fail its tests.
public getStringCellWidth(s: string): number {
- throw new Error('Method not implemented.');
+ return UnicodeService.prototype.getStringCellWidth.call(this, s);
}
}
+24 -24
View File
@@ -19,7 +19,7 @@ patchedDependencies:
hash: 6da7d7770b6427246f2a0d057d97da418040e498068b41d0c2d3c6b20bf49258
path: config/patches/@xterm__addon-webgl@0.20.0-beta.286.patch
'@xterm/xterm@6.1.0-beta.287':
hash: 8a8976e1ddd73b3747547f119f76a72f2fa3f8e6efc6e6134b267d9c7f80f65d
hash: 8d63166272e9040a343ff7070fb5bf44d9b3465b8042211765341c5483db55a6
path: config/patches/@xterm__xterm@6.1.0-beta.287.patch
node-pty@1.1.0:
hash: 8fc49f17011b6611a5b8c00e83a6f12e14e75aada2b0ef26dc5393f8376d20e8
@@ -46,7 +46,7 @@ importers:
version: 2.5.6
'@xterm/addon-serialize':
specifier: 0.15.0-beta.287
version: 0.15.0-beta.287(patch_hash=96f70e83261df6a29ad7590feb08b988670655ced7596e77253d524f73f608dd)(@xterm/xterm@6.1.0-beta.287(patch_hash=8a8976e1ddd73b3747547f119f76a72f2fa3f8e6efc6e6134b267d9c7f80f65d))
version: 0.15.0-beta.287(patch_hash=96f70e83261df6a29ad7590feb08b988670655ced7596e77253d524f73f608dd)(@xterm/xterm@6.1.0-beta.287(patch_hash=8d63166272e9040a343ff7070fb5bf44d9b3465b8042211765341c5483db55a6))
'@xterm/headless':
specifier: 6.1.0-beta.287
version: 6.1.0-beta.287
@@ -206,25 +206,25 @@ importers:
version: 5.2.0(rolldown-vite@7.3.1(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2)(@types/node@25.9.5)(jiti@2.7.0)(yaml@2.8.4))
'@xterm/addon-fit':
specifier: 0.12.0-beta.287
version: 0.12.0-beta.287(@xterm/xterm@6.1.0-beta.287(patch_hash=8a8976e1ddd73b3747547f119f76a72f2fa3f8e6efc6e6134b267d9c7f80f65d))
version: 0.12.0-beta.287(@xterm/xterm@6.1.0-beta.287(patch_hash=8d63166272e9040a343ff7070fb5bf44d9b3465b8042211765341c5483db55a6))
'@xterm/addon-ligatures':
specifier: 0.11.0-beta.287
version: 0.11.0-beta.287(patch_hash=47405b9994b5acf1b4e90b49250358c1ca03649854d59560e7732b72fe336920)(@xterm/xterm@6.1.0-beta.287(patch_hash=8a8976e1ddd73b3747547f119f76a72f2fa3f8e6efc6e6134b267d9c7f80f65d))
version: 0.11.0-beta.287(patch_hash=47405b9994b5acf1b4e90b49250358c1ca03649854d59560e7732b72fe336920)(@xterm/xterm@6.1.0-beta.287(patch_hash=8d63166272e9040a343ff7070fb5bf44d9b3465b8042211765341c5483db55a6))
'@xterm/addon-search':
specifier: 0.17.0-beta.287
version: 0.17.0-beta.287(@xterm/xterm@6.1.0-beta.287(patch_hash=8a8976e1ddd73b3747547f119f76a72f2fa3f8e6efc6e6134b267d9c7f80f65d))
version: 0.17.0-beta.287(@xterm/xterm@6.1.0-beta.287(patch_hash=8d63166272e9040a343ff7070fb5bf44d9b3465b8042211765341c5483db55a6))
'@xterm/addon-unicode11':
specifier: 0.10.0-beta.287
version: 0.10.0-beta.287(@xterm/xterm@6.1.0-beta.287(patch_hash=8a8976e1ddd73b3747547f119f76a72f2fa3f8e6efc6e6134b267d9c7f80f65d))
version: 0.10.0-beta.287(@xterm/xterm@6.1.0-beta.287(patch_hash=8d63166272e9040a343ff7070fb5bf44d9b3465b8042211765341c5483db55a6))
'@xterm/addon-web-links':
specifier: 0.13.0-beta.287
version: 0.13.0-beta.287(@xterm/xterm@6.1.0-beta.287(patch_hash=8a8976e1ddd73b3747547f119f76a72f2fa3f8e6efc6e6134b267d9c7f80f65d))
version: 0.13.0-beta.287(@xterm/xterm@6.1.0-beta.287(patch_hash=8d63166272e9040a343ff7070fb5bf44d9b3465b8042211765341c5483db55a6))
'@xterm/addon-webgl':
specifier: 0.20.0-beta.286
version: 0.20.0-beta.286(patch_hash=6da7d7770b6427246f2a0d057d97da418040e498068b41d0c2d3c6b20bf49258)(@xterm/xterm@6.1.0-beta.287(patch_hash=8a8976e1ddd73b3747547f119f76a72f2fa3f8e6efc6e6134b267d9c7f80f65d))
version: 0.20.0-beta.286(patch_hash=6da7d7770b6427246f2a0d057d97da418040e498068b41d0c2d3c6b20bf49258)(@xterm/xterm@6.1.0-beta.287(patch_hash=8d63166272e9040a343ff7070fb5bf44d9b3465b8042211765341c5483db55a6))
'@xterm/xterm':
specifier: 6.1.0-beta.287
version: 6.1.0-beta.287(patch_hash=8a8976e1ddd73b3747547f119f76a72f2fa3f8e6efc6e6134b267d9c7f80f65d)
version: 6.1.0-beta.287(patch_hash=8d63166272e9040a343ff7070fb5bf44d9b3465b8042211765341c5483db55a6)
class-variance-authority:
specifier: ^0.7.1
version: 0.7.1
@@ -9582,39 +9582,39 @@ snapshots:
'@xmldom/xmldom@0.8.13': {}
'@xterm/addon-fit@0.12.0-beta.287(@xterm/xterm@6.1.0-beta.287(patch_hash=8a8976e1ddd73b3747547f119f76a72f2fa3f8e6efc6e6134b267d9c7f80f65d))':
'@xterm/addon-fit@0.12.0-beta.287(@xterm/xterm@6.1.0-beta.287(patch_hash=8d63166272e9040a343ff7070fb5bf44d9b3465b8042211765341c5483db55a6))':
dependencies:
'@xterm/xterm': 6.1.0-beta.287(patch_hash=8a8976e1ddd73b3747547f119f76a72f2fa3f8e6efc6e6134b267d9c7f80f65d)
'@xterm/xterm': 6.1.0-beta.287(patch_hash=8d63166272e9040a343ff7070fb5bf44d9b3465b8042211765341c5483db55a6)
'@xterm/addon-ligatures@0.11.0-beta.287(patch_hash=47405b9994b5acf1b4e90b49250358c1ca03649854d59560e7732b72fe336920)(@xterm/xterm@6.1.0-beta.287(patch_hash=8a8976e1ddd73b3747547f119f76a72f2fa3f8e6efc6e6134b267d9c7f80f65d))':
'@xterm/addon-ligatures@0.11.0-beta.287(patch_hash=47405b9994b5acf1b4e90b49250358c1ca03649854d59560e7732b72fe336920)(@xterm/xterm@6.1.0-beta.287(patch_hash=8d63166272e9040a343ff7070fb5bf44d9b3465b8042211765341c5483db55a6))':
dependencies:
'@xterm/xterm': 6.1.0-beta.287(patch_hash=8a8976e1ddd73b3747547f119f76a72f2fa3f8e6efc6e6134b267d9c7f80f65d)
'@xterm/xterm': 6.1.0-beta.287(patch_hash=8d63166272e9040a343ff7070fb5bf44d9b3465b8042211765341c5483db55a6)
lru-cache: 11.5.1
opentype.js: 2.0.0
'@xterm/addon-search@0.17.0-beta.287(@xterm/xterm@6.1.0-beta.287(patch_hash=8a8976e1ddd73b3747547f119f76a72f2fa3f8e6efc6e6134b267d9c7f80f65d))':
'@xterm/addon-search@0.17.0-beta.287(@xterm/xterm@6.1.0-beta.287(patch_hash=8d63166272e9040a343ff7070fb5bf44d9b3465b8042211765341c5483db55a6))':
dependencies:
'@xterm/xterm': 6.1.0-beta.287(patch_hash=8a8976e1ddd73b3747547f119f76a72f2fa3f8e6efc6e6134b267d9c7f80f65d)
'@xterm/xterm': 6.1.0-beta.287(patch_hash=8d63166272e9040a343ff7070fb5bf44d9b3465b8042211765341c5483db55a6)
'@xterm/addon-serialize@0.15.0-beta.287(patch_hash=96f70e83261df6a29ad7590feb08b988670655ced7596e77253d524f73f608dd)(@xterm/xterm@6.1.0-beta.287(patch_hash=8a8976e1ddd73b3747547f119f76a72f2fa3f8e6efc6e6134b267d9c7f80f65d))':
'@xterm/addon-serialize@0.15.0-beta.287(patch_hash=96f70e83261df6a29ad7590feb08b988670655ced7596e77253d524f73f608dd)(@xterm/xterm@6.1.0-beta.287(patch_hash=8d63166272e9040a343ff7070fb5bf44d9b3465b8042211765341c5483db55a6))':
dependencies:
'@xterm/xterm': 6.1.0-beta.287(patch_hash=8a8976e1ddd73b3747547f119f76a72f2fa3f8e6efc6e6134b267d9c7f80f65d)
'@xterm/xterm': 6.1.0-beta.287(patch_hash=8d63166272e9040a343ff7070fb5bf44d9b3465b8042211765341c5483db55a6)
'@xterm/addon-unicode11@0.10.0-beta.287(@xterm/xterm@6.1.0-beta.287(patch_hash=8a8976e1ddd73b3747547f119f76a72f2fa3f8e6efc6e6134b267d9c7f80f65d))':
'@xterm/addon-unicode11@0.10.0-beta.287(@xterm/xterm@6.1.0-beta.287(patch_hash=8d63166272e9040a343ff7070fb5bf44d9b3465b8042211765341c5483db55a6))':
dependencies:
'@xterm/xterm': 6.1.0-beta.287(patch_hash=8a8976e1ddd73b3747547f119f76a72f2fa3f8e6efc6e6134b267d9c7f80f65d)
'@xterm/xterm': 6.1.0-beta.287(patch_hash=8d63166272e9040a343ff7070fb5bf44d9b3465b8042211765341c5483db55a6)
'@xterm/addon-web-links@0.13.0-beta.287(@xterm/xterm@6.1.0-beta.287(patch_hash=8a8976e1ddd73b3747547f119f76a72f2fa3f8e6efc6e6134b267d9c7f80f65d))':
'@xterm/addon-web-links@0.13.0-beta.287(@xterm/xterm@6.1.0-beta.287(patch_hash=8d63166272e9040a343ff7070fb5bf44d9b3465b8042211765341c5483db55a6))':
dependencies:
'@xterm/xterm': 6.1.0-beta.287(patch_hash=8a8976e1ddd73b3747547f119f76a72f2fa3f8e6efc6e6134b267d9c7f80f65d)
'@xterm/xterm': 6.1.0-beta.287(patch_hash=8d63166272e9040a343ff7070fb5bf44d9b3465b8042211765341c5483db55a6)
'@xterm/addon-webgl@0.20.0-beta.286(patch_hash=6da7d7770b6427246f2a0d057d97da418040e498068b41d0c2d3c6b20bf49258)(@xterm/xterm@6.1.0-beta.287(patch_hash=8a8976e1ddd73b3747547f119f76a72f2fa3f8e6efc6e6134b267d9c7f80f65d))':
'@xterm/addon-webgl@0.20.0-beta.286(patch_hash=6da7d7770b6427246f2a0d057d97da418040e498068b41d0c2d3c6b20bf49258)(@xterm/xterm@6.1.0-beta.287(patch_hash=8d63166272e9040a343ff7070fb5bf44d9b3465b8042211765341c5483db55a6))':
dependencies:
'@xterm/xterm': 6.1.0-beta.287(patch_hash=8a8976e1ddd73b3747547f119f76a72f2fa3f8e6efc6e6134b267d9c7f80f65d)
'@xterm/xterm': 6.1.0-beta.287(patch_hash=8d63166272e9040a343ff7070fb5bf44d9b3465b8042211765341c5483db55a6)
'@xterm/headless@6.1.0-beta.287': {}
'@xterm/xterm@6.1.0-beta.287(patch_hash=8a8976e1ddd73b3747547f119f76a72f2fa3f8e6efc6e6134b267d9c7f80f65d)': {}
'@xterm/xterm@6.1.0-beta.287(patch_hash=8d63166272e9040a343ff7070fb5bf44d9b3465b8042211765341c5483db55a6)': {}
abbrev@4.0.0: {}
@@ -0,0 +1,207 @@
// @vitest-environment happy-dom
/**
* The preedit overlay is laid out as plain text, so before the patch its extent was
* the font's advance for the composed string rather than the cells that string will
* occupy once committed. Measured in Electron 43 / Chromium 150 on macOS with Orca's
* shipped stack (SF Mono 14px/300, cell 8.65x16), overlay width against
* `wcwidth x cell.width`:
*
* abcdefgh 69.234px / 69.20px = 1.000 (control)
* 가나다라 48.453px / 69.20px = 0.700
* 가나다라 committed 69.203px / 69.20px = 1.000
* 日本語 42.000px / 51.90px = 0.809
*
* The same four syllables render 30% narrower while composing than they do once the
* terminal paints them, drifting 20.75px by the fourth. The patch spreads the
* shortfall as letter-spacing, the mechanism DomRendererRowFactory already uses to
* land committed glyphs on the grid.
*
* Caveat: those pixel ratios are macOS/SF Mono. STA-3232's reporter is on Windows,
* whose font stack was not measured; the arithmetic asserted here is font-independent,
* the visual consequence is not.
*
* happy-dom has no text layout, so the font advances below stand in for it — the
* assertions are about the width the patch derives, not about real glyph rasterisation.
*/
import { Terminal } from '@xterm/xterm'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
/** Per-character advances measured in the Chromium rig, keyed by script. */
const LATIN_ADVANCE_PX = 8.654
const HANGUL_ADVANCE_PX = 12.125
const CJK_ADVANCE_PX = 14
const CELL_WIDTH_PX = 8.65
const CELL_HEIGHT_PX = 16
function advanceOf(character: string): number {
const codePoint = character.codePointAt(0) ?? 0
if (codePoint >= 0x3040 && codePoint <= 0x9fff) {
return CJK_ADVANCE_PX
}
if (codePoint >= 0x1100 && codePoint <= 0x11ff) {
return HANGUL_ADVANCE_PX
}
if (codePoint >= 0x3130 && codePoint <= 0x318f) {
return HANGUL_ADVANCE_PX
}
if (codePoint >= 0xac00 && codePoint <= 0xd7a3) {
return HANGUL_ADVANCE_PX
}
// The U+200E marks xterm wraps the preedit in take no advance and no spacing.
if (codePoint === 0x200e) {
return 0
}
return LATIN_ADVANCE_PX
}
/**
* Chromium's own rule, confirmed against the rig: letter-spacing lands after every
* character that advances and after none that does not.
*/
function laidOutWidth(view: HTMLElement): number {
const spacing = Number.parseFloat(view.style.letterSpacing || '0') || 0
let width = 0
for (const character of view.textContent ?? '') {
const advance = advanceOf(character)
if (advance > 0) {
width += advance + spacing
}
}
return width
}
type Harness = {
cell: { width: number; height: number }
compose: (data: string) => void
terminal: Terminal
view: HTMLElement
}
const openTerminals: { terminal: Terminal; textarea: HTMLTextAreaElement }[] = []
function openComposingTerminal(): Harness {
const container = document.createElement('div')
document.body.appendChild(container)
const terminal = new Terminal({ cols: 80, rows: 24 })
terminal.open(container)
const textarea = terminal.textarea
const view = container.querySelector<HTMLElement>('.composition-view')
if (!textarea || !view) {
throw new Error('xterm did not create the helper textarea and composition view')
}
openTerminals.push({ terminal, textarea })
// happy-dom measures no text, so the terminal's own cell size has to be supplied.
const cell = (
terminal as unknown as {
_core: {
_renderService: { dimensions: { css: { cell: { height: number; width: number } } } }
}
}
)._core._renderService.dimensions.css.cell
cell.width = CELL_WIDTH_PX
cell.height = CELL_HEIGHT_PX
view.getBoundingClientRect = () =>
({ height: CELL_HEIGHT_PX, width: laidOutWidth(view) }) as DOMRect
const compose = (data: string): void => {
const event = new CompositionEvent('compositionupdate', { bubbles: true })
Object.defineProperty(event, 'data', { value: data })
textarea.dispatchEvent(event)
}
textarea.dispatchEvent(new CompositionEvent('compositionstart', { bubbles: true }))
return { cell, compose, terminal, view }
}
/** What xterm's own unicode service says the string occupies in the grid. */
function gridWidthPx(terminal: Terminal, text: string, cellWidth = CELL_WIDTH_PX): number {
const unicodeService = (
terminal as unknown as {
_core: { unicodeService: { getStringCellWidth: (s: string) => number } }
}
)._core.unicodeService
return unicodeService.getStringCellWidth(text) * cellWidth
}
describe('preedit overlay grid width', () => {
beforeEach(() => {
// happy-dom has no 2d context, which the DOM renderer's WidthCache requires.
vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue({
measureText: () => ({ width: 10 })
} as unknown as CanvasRenderingContext2D)
})
afterEach(async () => {
// updateCompositionElements re-arms itself on a timer, so end the composition and
// let the pending one run before the render service it reads goes away.
for (const { textarea } of openTerminals) {
textarea.dispatchEvent(new CompositionEvent('compositionend', { bubbles: true }))
}
await new Promise((resolve) => setTimeout(resolve, 0))
while (openTerminals.length > 0) {
openTerminals.pop()?.terminal.dispose()
}
vi.restoreAllMocks()
document.body.replaceChildren()
})
it('sizes a Korean preedit to the cells it will occupy once committed', () => {
const { compose, terminal, view } = openComposingTerminal()
compose('가나다라')
// 4 syllables x 2 cells x 8.65 = 69.2, against a 48.5 natural advance.
expect(gridWidthPx(terminal, '가나다라')).toBeCloseTo(69.2, 6)
expect(laidOutWidth(view)).toBeCloseTo(69.2, 6)
// (69.2 - 4 x 12.125) / 4 gaps
expect(Number.parseFloat(view.style.letterSpacing)).toBeCloseTo(5.175, 6)
})
it('leaves a Latin preedit at the width it already had', () => {
const { compose, terminal, view } = openComposingTerminal()
compose('abcdefgh')
expect(laidOutWidth(view)).toBeCloseTo(gridWidthPx(terminal, 'abcdefgh'), 6)
// The control arm measured 1.000 before the patch; the correction must stay in the noise.
expect(Math.abs(Number.parseFloat(view.style.letterSpacing))).toBeLessThan(0.01)
})
it('sizes a CJK preedit to the cells it will occupy once committed', () => {
const { compose, terminal, view } = openComposingTerminal()
compose('日本語')
expect(gridWidthPx(terminal, '日本語')).toBeCloseTo(51.9, 6)
expect(laidOutWidth(view)).toBeCloseTo(51.9, 6)
})
it('resizes when the composition grows a syllable at a time', () => {
const { compose, terminal, view } = openComposingTerminal()
for (const preedit of ['ㅇ', '아', '안', '안ㄴ', '안녕']) {
compose(preedit)
expect(laidOutWidth(view)).toBeCloseTo(gridWidthPx(terminal, preedit), 6)
}
})
it('re-derives the width when the cell size changes under an unchanged preedit', () => {
const { cell, compose, terminal, view } = openComposingTerminal()
compose('가나다라')
cell.width = 12
compose('가나다라')
expect(laidOutWidth(view)).toBeCloseTo(gridWidthPx(terminal, '가나다라', 12), 6)
})
it('leaves an empty preedit alone', () => {
const { compose, view } = openComposingTerminal()
compose('')
expect(view.style.letterSpacing).toBe('')
})
})