fix(tui): handle wide unicode input scrolling closes #1982
This commit is contained in:
@@ -2,6 +2,10 @@
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Fixed interactive input fields backed by the TUI `Input` component to scroll by visual column width for wide Unicode text (CJK, fullwidth characters), preventing rendered line overflow and TUI crashes in places like search and filter inputs ([#1982](https://github.com/badlogic/pi-mono/issues/1982))
|
||||||
|
|
||||||
## [0.57.1] - 2026-03-07
|
## [0.57.1] - 2026-03-07
|
||||||
|
|
||||||
### New Features
|
### New Features
|
||||||
|
|||||||
@@ -2,6 +2,10 @@
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Fixed `Input` horizontal scrolling for wide Unicode text (CJK, fullwidth characters) to use visual column width instead of string length, preventing rendered line overflow and TUI crashes ([#1982](https://github.com/badlogic/pi-mono/issues/1982))
|
||||||
|
|
||||||
## [0.57.1] - 2026-03-07
|
## [0.57.1] - 2026-03-07
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { decodeKittyPrintable } from "../keys.js";
|
|||||||
import { KillRing } from "../kill-ring.js";
|
import { KillRing } from "../kill-ring.js";
|
||||||
import { type Component, CURSOR_MARKER, type Focusable } from "../tui.js";
|
import { type Component, CURSOR_MARKER, type Focusable } from "../tui.js";
|
||||||
import { UndoStack } from "../undo-stack.js";
|
import { UndoStack } from "../undo-stack.js";
|
||||||
import { getSegmenter, isPunctuationChar, isWhitespaceChar, visibleWidth } from "../utils.js";
|
import { getSegmenter, isPunctuationChar, isWhitespaceChar, sliceByColumn, visibleWidth } from "../utils.js";
|
||||||
|
|
||||||
const segmenter = getSegmenter();
|
const segmenter = getSegmenter();
|
||||||
|
|
||||||
@@ -442,56 +442,38 @@ export class Input implements Component, Focusable {
|
|||||||
|
|
||||||
let visibleText = "";
|
let visibleText = "";
|
||||||
let cursorDisplay = this.cursor;
|
let cursorDisplay = this.cursor;
|
||||||
|
const totalWidth = visibleWidth(this.value);
|
||||||
|
|
||||||
if (this.value.length < availableWidth) {
|
if (totalWidth < availableWidth) {
|
||||||
// Everything fits (leave room for cursor at end)
|
// Everything fits (leave room for cursor at end)
|
||||||
visibleText = this.value;
|
visibleText = this.value;
|
||||||
} else {
|
} else {
|
||||||
// Need horizontal scrolling
|
// Need horizontal scrolling
|
||||||
// Reserve one character for cursor if it's at the end
|
// Reserve one column for cursor if it's at the end
|
||||||
const scrollWidth = this.cursor === this.value.length ? availableWidth - 1 : availableWidth;
|
const scrollWidth = this.cursor === this.value.length ? availableWidth - 1 : availableWidth;
|
||||||
const halfWidth = Math.floor(scrollWidth / 2);
|
const cursorCol = visibleWidth(this.value.slice(0, this.cursor));
|
||||||
|
|
||||||
const findValidStart = (start: number) => {
|
if (scrollWidth > 0) {
|
||||||
while (start < this.value.length) {
|
const halfWidth = Math.floor(scrollWidth / 2);
|
||||||
const charCode = this.value.charCodeAt(start);
|
let startCol = 0;
|
||||||
// this is low surrogate, not a valid start
|
|
||||||
if (charCode >= 0xdc00 && charCode < 0xe000) {
|
if (cursorCol < halfWidth) {
|
||||||
start++;
|
// Cursor near start
|
||||||
continue;
|
startCol = 0;
|
||||||
}
|
} else if (cursorCol > totalWidth - halfWidth) {
|
||||||
break;
|
// Cursor near end
|
||||||
|
startCol = Math.max(0, totalWidth - scrollWidth);
|
||||||
|
} else {
|
||||||
|
// Cursor in middle
|
||||||
|
startCol = Math.max(0, cursorCol - halfWidth);
|
||||||
}
|
}
|
||||||
return start;
|
|
||||||
};
|
|
||||||
|
|
||||||
const findValidEnd = (end: number) => {
|
visibleText = sliceByColumn(this.value, startCol, scrollWidth);
|
||||||
while (end > 0) {
|
const beforeCursor = sliceByColumn(this.value, startCol, Math.max(0, cursorCol - startCol));
|
||||||
const charCode = this.value.charCodeAt(end - 1);
|
cursorDisplay = beforeCursor.length;
|
||||||
// this is high surrogate, might be split.
|
|
||||||
if (charCode >= 0xd800 && charCode < 0xdc00) {
|
|
||||||
end--;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
return end;
|
|
||||||
};
|
|
||||||
|
|
||||||
if (this.cursor < halfWidth) {
|
|
||||||
// Cursor near start
|
|
||||||
visibleText = this.value.slice(0, findValidEnd(scrollWidth));
|
|
||||||
cursorDisplay = this.cursor;
|
|
||||||
} else if (this.cursor > this.value.length - halfWidth) {
|
|
||||||
// Cursor near end
|
|
||||||
const start = findValidStart(this.value.length - scrollWidth);
|
|
||||||
visibleText = this.value.slice(start);
|
|
||||||
cursorDisplay = this.cursor - start;
|
|
||||||
} else {
|
} else {
|
||||||
// Cursor in middle
|
visibleText = "";
|
||||||
const start = findValidStart(this.cursor - halfWidth);
|
cursorDisplay = 0;
|
||||||
visibleText = this.value.slice(start, findValidEnd(start + scrollWidth));
|
|
||||||
cursorDisplay = halfWidth;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import assert from "node:assert";
|
import assert from "node:assert";
|
||||||
import { describe, it } from "node:test";
|
import { describe, it } from "node:test";
|
||||||
import { Input } from "../src/components/input.js";
|
import { Input } from "../src/components/input.js";
|
||||||
|
import { visibleWidth } from "../src/utils.js";
|
||||||
|
|
||||||
describe("Input component", () => {
|
describe("Input component", () => {
|
||||||
it("submits value including backslash on Enter", () => {
|
it("submits value including backslash on Enter", () => {
|
||||||
@@ -33,6 +34,42 @@ describe("Input component", () => {
|
|||||||
assert.strictEqual(input.getValue(), "\\x");
|
assert.strictEqual(input.getValue(), "\\x");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("render", () => {
|
||||||
|
it("does not overflow with wide CJK and fullwidth text", () => {
|
||||||
|
const width = 93;
|
||||||
|
const cases = [
|
||||||
|
"가나다라마바사아자차카타파하 한글 텍스트가 터미널 너비를 초과하면 크래시가 발생합니다 이것은 재현용 테스트입니다",
|
||||||
|
"これはテスト文章です。日本語のテキストが正しく表示されるかどうかを確認するためのサンプルテキストです。あいうえお",
|
||||||
|
"这是一段测试文本,用于验证中文字符在终端中的显示宽度是否被正确计算,如果不正确就会导致用户界面崩溃的问题",
|
||||||
|
"ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789abcdefghijklm",
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const text of cases) {
|
||||||
|
const input = new Input();
|
||||||
|
input.setValue(text);
|
||||||
|
input.focused = true;
|
||||||
|
|
||||||
|
const [line] = input.render(width);
|
||||||
|
assert.ok(line);
|
||||||
|
assert.ok(visibleWidth(line) <= width, `rendered line overflowed for ${text}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps the cursor visible when horizontally scrolling wide text", () => {
|
||||||
|
const input = new Input();
|
||||||
|
const width = 20;
|
||||||
|
const text = "가나다라마바사아자차카타파하";
|
||||||
|
input.setValue(text);
|
||||||
|
input.focused = true;
|
||||||
|
input.handleInput("\x01");
|
||||||
|
for (let i = 0; i < 5; i++) input.handleInput("\x1b[C");
|
||||||
|
|
||||||
|
const [line] = input.render(width);
|
||||||
|
assert.ok(line);
|
||||||
|
assert.ok(visibleWidth(line) <= width);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("Kill ring", () => {
|
describe("Kill ring", () => {
|
||||||
it("Ctrl+W saves deleted text to kill ring and Ctrl+Y yanks it", () => {
|
it("Ctrl+W saves deleted text to kill ring and Ctrl+Y yanks it", () => {
|
||||||
const input = new Input();
|
const input = new Input();
|
||||||
|
|||||||
Reference in New Issue
Block a user