fix(terminal): 修正宽字符拖选边界

This commit is contained in:
victorwon2001 committed 2026-09-11 13:25:16 +09:00
1 parent bc20da042e
commit 319f4c952d
3 files changed
+277 -1

No files matched your search

+1 -1
View File
@@ -36,7 +36,7 @@
"fmt:rust:check": "cargo fmt --manifest-path src-tauri/Cargo.toml -- --check",
"i18n:check": "prettier --check src/i18n/locales/*.json",
"i18n:fix": "prettier --write src/i18n/locales/*.json",
"postinstall": "node scripts/patch-xterm-webgl-clear-model.cjs"
"postinstall": "node scripts/patch-xterm-webgl-clear-model.cjs && node scripts/patch-xterm-wide-char-selection.cjs"
},
"dependencies": {
"@codemirror/autocomplete": "^6.20.3",
+214
View File
@@ -0,0 +1,214 @@
#!/usr/bin/env node
/* global process, console */
/**
* Fix @xterm/xterm drag-selection boundaries for width-2 characters.
*
* xterm's selection coordinates use a half-cell offset. When that lands on the
* continuation cell of a width-2 character, SelectionService currently always
* advances to the character's right boundary. This makes the effective boundary
* occur around one quarter of the rendered glyph instead of at its visual center.
*
* Fix: only for normal mouse selection, use the same relative pointer coordinate
* helper and rendered cell width that xterm already uses. A continuation-cell hit
* snaps left before the wide character on its visual left half and right after it
* on its visual right half. Other selection modes retain xterm's existing logic.
*
* This script is intended for:
* @xterm/xterm@6.1.0-beta.288
*
* It is idempotent and fails fast when the installed package or minified bundle
* no longer matches the expected version, so dependency upgrades cannot silently
* reintroduce the issue.
*/
"use strict";
const fs = require("node:fs");
const path = require("node:path");
const PACKAGE_NAME = "@xterm/xterm";
const EXPECTED_VERSION = "6.1.0-beta.288";
const MARKER = "/*nyaterm:wide-char-selection-midpoint*/";
const STATE = "_nyatermWideCharMidpointSelection";
const PACKAGE_DIR = path.resolve(
process.cwd(),
"node_modules",
"@xterm",
"xterm",
);
const PACKAGE_JSON = path.join(PACKAGE_DIR, "package.json");
const TARGETS = [
{
file: path.join(PACKAGE_DIR, "lib", "xterm.mjs"),
// @xterm/xterm@6.1.0-beta.288 ESM bundle
replacements: [
{
label: "normal selection drag state",
original:
"e.preventDefault(),this._dragScrollAmount=0,this._enabled&&e.shiftKey?this._handleIncrementalClick(e)",
patched: `e.preventDefault(),this._dragScrollAmount=0,${MARKER}this.${STATE}=this._enabled&&!e.shiftKey&&!e.altKey&&!e.ctrlKey&&!e.metaKey&&e.detail===1,this._enabled&&e.shiftKey?this._handleIncrementalClick(e)`,
},
{
label: "normal selection start",
original:
"r&&r.length!==this._model.selectionStart[0]&&r.hasWidth(this._model.selectionStart[0])===0&&this._model.selectionStart[0]++",
patched: `r&&r.length!==this._model.selectionStart[0]&&r.hasWidth(this._model.selectionStart[0])===0&&(this.${STATE}?this._model.selectionStart[0]+=qt(this._coreBrowserService.window,e,this._screenElement)[0]<=this._model.selectionStart[0]*this._renderService.dimensions.css.cell.width?-1:1:this._model.selectionStart[0]++)`,
},
{
label: "normal selection end",
original:
"s&&s.hasWidth(this._model.selectionEnd[0])===0&&this._model.selectionEnd[0]<this._bufferService.cols&&this._model.selectionEnd[0]++",
patched: `s&&s.hasWidth(this._model.selectionEnd[0])===0&&this._model.selectionEnd[0]<this._bufferService.cols&&(this.${STATE}?this._model.selectionEnd[0]+=qt(this._coreBrowserService.window,e,this._screenElement)[0]<=this._model.selectionEnd[0]*this._renderService.dimensions.css.cell.width?-1:1:this._model.selectionEnd[0]++)`,
},
],
},
{
file: path.join(PACKAGE_DIR, "lib", "xterm.js"),
// @xterm/xterm@6.1.0-beta.288 CJS bundle
replacements: [
{
label: "normal selection drag state",
original:
"e.preventDefault(),this._dragScrollAmount=0,this._enabled&&e.shiftKey?this._handleIncrementalClick(e)",
patched: `e.preventDefault(),this._dragScrollAmount=0,${MARKER}this.${STATE}=this._enabled&&!e.shiftKey&&!e.altKey&&!e.ctrlKey&&!e.metaKey&&1===e.detail,this._enabled&&e.shiftKey?this._handleIncrementalClick(e)`,
},
{
label: "normal selection start",
original:
"i&&i.length!==this._model.selectionStart[0]&&0===i.hasWidth(this._model.selectionStart[0])&&this._model.selectionStart[0]++",
patched: `i&&i.length!==this._model.selectionStart[0]&&0===i.hasWidth(this._model.selectionStart[0])&&(this.${STATE}?this._model.selectionStart[0]+=(0,l.getCoordsRelativeToElement)(this._coreBrowserService.window,e,this._screenElement)[0]<=this._model.selectionStart[0]*this._renderService.dimensions.css.cell.width?-1:1:this._model.selectionStart[0]++)`,
},
{
label: "normal selection end",
original:
"const i=this._bufferService.buffer;if(this._model.selectionEnd[1]<i.lines.length){const e=i.lines.get(this._model.selectionEnd[1]);e&&0===e.hasWidth(this._model.selectionEnd[0])&&this._model.selectionEnd[0]<this._bufferService.cols&&this._model.selectionEnd[0]++}",
patched: `const i=this._bufferService.buffer;if(this._model.selectionEnd[1]<i.lines.length){const s=i.lines.get(this._model.selectionEnd[1]);s&&0===s.hasWidth(this._model.selectionEnd[0])&&this._model.selectionEnd[0]<this._bufferService.cols&&(this.${STATE}?this._model.selectionEnd[0]+=(0,l.getCoordsRelativeToElement)(this._coreBrowserService.window,e,this._screenElement)[0]<=this._model.selectionEnd[0]*this._renderService.dimensions.css.cell.width?-1:1:this._model.selectionEnd[0]++)}`,
},
],
},
];
function fail(message) {
throw new Error(`[patch-xterm-wide-char-selection] ${message}`);
}
function countOccurrences(source, needle) {
return source.split(needle).length - 1;
}
function writeFileAtomically(filename, content) {
const temporary = `${filename}.nyaterm-patch-${process.pid}.tmp`;
fs.writeFileSync(temporary, content, "utf8");
fs.renameSync(temporary, filename);
}
function verifyPatchedSource(source, relative, replacements) {
const markerCount = countOccurrences(source, MARKER);
if (markerCount !== 1) {
fail(
`expected exactly one patch marker in ${relative}, found ${markerCount}`,
);
}
for (const replacement of replacements) {
const originalCount = countOccurrences(source, replacement.original);
const patchedCount = countOccurrences(source, replacement.patched);
if (originalCount !== 0 || patchedCount !== 1) {
fail(
`patch verification failed for ${replacement.label} in ${relative}: ` +
`original=${originalCount} patched=${patchedCount}`,
);
}
}
}
if (!fs.existsSync(PACKAGE_JSON)) {
fail(`${PACKAGE_NAME} is not installed: ${PACKAGE_JSON}`);
}
let packageJson;
try {
packageJson = JSON.parse(fs.readFileSync(PACKAGE_JSON, "utf8"));
} catch (error) {
fail(
`cannot read ${PACKAGE_JSON}: ${
error instanceof Error ? error.message : String(error)
}`,
);
}
if (packageJson.version !== EXPECTED_VERSION) {
fail(
`unsupported ${PACKAGE_NAME} version ${JSON.stringify(packageJson.version)}; ` +
`expected ${EXPECTED_VERSION}. Review and refresh this patch before upgrading.`,
);
}
let patched = 0;
let already = 0;
for (const { file, replacements } of TARGETS) {
const relative = path.relative(process.cwd(), file);
if (!fs.existsSync(file)) {
fail(`runtime bundle not found: ${relative}`);
}
let source;
try {
source = fs.readFileSync(file, "utf8");
} catch (error) {
fail(
`cannot read ${relative}: ${
error instanceof Error ? error.message : String(error)
}`,
);
}
if (source.includes(MARKER)) {
verifyPatchedSource(source, relative, replacements);
already += 1;
continue;
}
for (const replacement of replacements) {
const occurrences = countOccurrences(source, replacement.original);
if (occurrences !== 1) {
fail(
`expected exactly one ${replacement.label} fragment in ${relative}, ` +
`found ${occurrences}. The minified xterm bundle may have changed.`,
);
}
if (source.includes(replacement.patched)) {
fail(`unexpected partial patch for ${replacement.label} in ${relative}`);
}
}
let patchedSource = source;
for (const replacement of replacements) {
patchedSource = patchedSource.replace(
replacement.original,
replacement.patched,
);
}
verifyPatchedSource(patchedSource, relative, replacements);
try {
writeFileAtomically(file, patchedSource);
} catch (error) {
fail(
`cannot write ${relative}: ${
error instanceof Error ? error.message : String(error)
}`,
);
}
patched += 1;
}
console.log(
`[patch-xterm-wide-char-selection] midpoint snapping: ` +
`patched=${patched} already=${already} total=${TARGETS.length}`,
);
@@ -0,0 +1,62 @@
import fs from "node:fs";
import path from "node:path";
import { describe, expect, it } from "vitest";
const packageDir = path.resolve(
process.cwd(),
"node_modules",
"@xterm",
"xterm",
"lib",
);
const cases = [
{
name: "ESM",
file: "xterm.mjs",
state:
"this._nyatermWideCharMidpointSelection=this._enabled&&!e.shiftKey&&!e.altKey&&!e.ctrlKey&&!e.metaKey&&e.detail===1",
startFallback: ":this._model.selectionStart[0]++)",
endFallback: ":this._model.selectionEnd[0]++)",
},
{
name: "CJS",
file: "xterm.js",
state:
"this._nyatermWideCharMidpointSelection=this._enabled&&!e.shiftKey&&!e.altKey&&!e.ctrlKey&&!e.metaKey&&1===e.detail",
startFallback: ":this._model.selectionStart[0]++)",
endFallback: ":this._model.selectionEnd[0]++)",
},
] as const;
describe("xterm wide-character selection patch", () => {
it.each(cases)(
"$name keeps Shift/modified selection on xterm's original wide-cell behavior",
({ file, state, startFallback, endFallback }) => {
const source = fs.readFileSync(path.join(packageDir, file), "utf8");
const stateName = "_nyatermWideCharMidpointSelection";
expect(source).toContain("/*nyaterm:wide-char-selection-midpoint*/");
expect(source).toContain(state);
expect(source.split(stateName)).toHaveLength(4);
const startUse = source.indexOf(
`this.${stateName}?this._model.selectionStart[0]+=`,
);
const endUse = source.indexOf(
`this.${stateName}?this._model.selectionEnd[0]+=`,
);
expect(startUse).toBeGreaterThan(-1);
expect(endUse).toBeGreaterThan(startUse);
expect(source.slice(startUse, startUse + 500)).toContain(startFallback);
expect(source.slice(endUse, endUse + 500)).toContain(endFallback);
// The decision is captured once on mousedown. Mousemove must not switch
// algorithms based on the modifier state of later events.
expect(source).not.toContain("_activeSelectionMode===0&&!e.shiftKey?");
expect(source).not.toContain(
"0===this._activeSelectionMode&&!e.shiftKey?",
);
},
);
});