fix(tui): truncate narrow editor scroll indicators (#7015)

Fixes #6962
This commit is contained in:
Christian Klotz 2026-07-23 21:47:11 +03:00 • committed by GitHub
parent 65ff8e7f6d
commit 37bedb7f0b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 45 additions and 11 deletions

View file

@ -2,6 +2,10 @@
## [Unreleased] ## [Unreleased]
### Fixed
- Fixed narrow terminals crashing when the editor's bottom scroll indicator exceeded the terminal width ([#6962](https://github.com/earendil-works/pi/issues/6962)).
## [0.81.1] - 2026-07-21 ## [0.81.1] - 2026-07-21
## [0.81.0] - 2026-07-21 ## [0.81.0] - 2026-07-21

View file

@ -9,7 +9,7 @@ import {
getGraphemeSegmenter, getGraphemeSegmenter,
getWordSegmenter, getWordSegmenter,
isWhitespaceChar, isWhitespaceChar,
truncateToWidth, sliceByColumn,
visibleWidth, visibleWidth,
} from "../utils.ts"; } from "../utils.ts";
import { findWordBackward, findWordForward } from "../word-navigation.ts"; import { findWordBackward, findWordForward } from "../word-navigation.ts";
@ -256,6 +256,17 @@ function buildDebouncePattern(triggerCharacters: string[]): RegExp {
return new RegExp(`(?:^|[ \\t])(?:@(?:"[^"]*|[^\\s]*)|[${escapedWithoutAt.join("")}][^\\s]*)$`); return new RegExp(`(?:^|[ \\t])(?:@(?:"[^"]*|[^\\s]*)|[${escapedWithoutAt.join("")}][^\\s]*)$`);
} }
function createScrollBorder(direction: "↑" | "↓", hiddenLineCount: number, width: number): string {
const availableWidth = Math.max(0, width);
const indicator = `─── ${direction} ${hiddenLineCount} more `;
const remaining = availableWidth - visibleWidth(indicator);
if (remaining >= 0) return indicator + "─".repeat(remaining);
const ellipsis = "...".slice(0, availableWidth);
const indicatorWidth = availableWidth - visibleWidth(ellipsis);
return sliceByColumn(indicator, 0, indicatorWidth, true) + ellipsis;
}
export class Editor implements Component, Focusable { export class Editor implements Component, Focusable {
private state: EditorState = { private state: EditorState = {
lines: [""], lines: [""],
@ -513,13 +524,8 @@ export class Editor implements Component, Focusable {
// Render top border (with scroll indicator if scrolled down) // Render top border (with scroll indicator if scrolled down)
if (this.scrollOffset > 0) { if (this.scrollOffset > 0) {
const indicator = `─── ↑ ${this.scrollOffset} more `; const border = createScrollBorder("↑", this.scrollOffset, width);
const remaining = width - visibleWidth(indicator); result.push(this.borderColor(border));
if (remaining >= 0) {
result.push(this.borderColor(indicator + "─".repeat(remaining)));
} else {
result.push(this.borderColor(truncateToWidth(indicator, width)));
}
} else { } else {
result.push(horizontal.repeat(width)); result.push(horizontal.repeat(width));
} }
@ -575,9 +581,8 @@ export class Editor implements Component, Focusable {
// Render bottom border (with scroll indicator if more content below) // Render bottom border (with scroll indicator if more content below)
const linesBelow = layoutLines.length - (this.scrollOffset + visibleLines.length); const linesBelow = layoutLines.length - (this.scrollOffset + visibleLines.length);
if (linesBelow > 0) { if (linesBelow > 0) {
const indicator = `─── ↓ ${linesBelow} more `; const border = createScrollBorder("↓", linesBelow, width);
const remaining = width - visibleWidth(indicator); result.push(this.borderColor(border));
result.push(this.borderColor(indicator + "─".repeat(Math.max(0, remaining))));
} else { } else {
result.push(horizontal.repeat(width)); result.push(horizontal.repeat(width));
} }

View file

@ -699,6 +699,31 @@ describe("Editor component", () => {
}); });
}); });
describe("Scroll indicators", () => {
it("keeps truncated scroll indicators within width and preserves their color (issue #6962)", () => {
const width = 10;
const borderColor = (text: string) => `\x1b[35m${text}\x1b[39m`;
const editor = new Editor(createTestTUI(width), { ...defaultEditorTheme, borderColor });
editor.setText(Array.from({ length: 20 }, (_, index) => `line ${index}`).join("\n"));
// Render once to initialize wrapping, then move the cursor so content remains above and below the viewport.
editor.render(width);
for (let index = 0; index < 10; index++) editor.handleInput("\x1b[A");
const lines = editor.render(width);
const topBorder = lines[0]!;
const bottomBorder = lines.at(-1)!;
assert.match(stripVTControlCharacters(topBorder), /^─── ↑/);
assert.match(stripVTControlCharacters(bottomBorder), /^─── ↓/);
assert.strictEqual(topBorder, borderColor(stripVTControlCharacters(topBorder)));
assert.strictEqual(bottomBorder, borderColor(stripVTControlCharacters(bottomBorder)));
for (const line of lines) {
assert.strictEqual(visibleWidth(line), width, `line exceeds width ${width}: ${JSON.stringify(line)}`);
}
});
});
describe("Grapheme-aware text wrapping", () => { describe("Grapheme-aware text wrapping", () => {
it("wraps lines correctly when text contains wide emojis", () => { it("wraps lines correctly when text contains wide emojis", () => {
const editor = new Editor(createTestTUI(), defaultEditorTheme); const editor = new Editor(createTestTUI(), defaultEditorTheme);