mirror of
https://github.com/getcompanion-ai/co-mono.git
synced 2026-04-15 09:01:14 +00:00
fix(tui): reserve 1 column for cursor in word wrap width
This commit is contained in:
parent
151099e17e
commit
b5ab90f837
2 changed files with 42 additions and 31 deletions
|
|
@ -306,13 +306,17 @@ export class Editor implements Component, Focusable {
|
|||
const paddingX = Math.min(this.paddingX, maxPadding);
|
||||
const contentWidth = Math.max(1, width - paddingX * 2);
|
||||
|
||||
// Store width for cursor navigation
|
||||
this.lastWidth = contentWidth;
|
||||
// Layout width: with padding the cursor can overflow into it,
|
||||
// without padding we reserve 1 column for the cursor.
|
||||
const layoutWidth = Math.max(1, contentWidth - (paddingX ? 0 : 1));
|
||||
|
||||
// Store for cursor navigation (must match wrapping width)
|
||||
this.lastWidth = layoutWidth;
|
||||
|
||||
const horizontal = this.borderColor("─");
|
||||
|
||||
// Layout the text - use content width
|
||||
const layoutLines = this.layoutText(contentWidth);
|
||||
// Layout the text
|
||||
const layoutLines = this.layoutText(layoutWidth);
|
||||
|
||||
// Calculate max visible lines: 30% of terminal height, minimum 5 lines
|
||||
const terminalRows = this.tui.terminal.rows;
|
||||
|
|
@ -356,6 +360,7 @@ export class Editor implements Component, Focusable {
|
|||
for (const layoutLine of visibleLines) {
|
||||
let displayText = layoutLine.text;
|
||||
let lineVisibleWidth = visibleWidth(layoutLine.text);
|
||||
let cursorInPadding = false;
|
||||
|
||||
// Add cursor if this line has it
|
||||
if (layoutLine.hasCursor && layoutLine.cursorPos !== undefined) {
|
||||
|
|
@ -375,37 +380,23 @@ export class Editor implements Component, Focusable {
|
|||
displayText = before + marker + cursor + restAfter;
|
||||
// lineVisibleWidth stays the same - we're replacing, not adding
|
||||
} else {
|
||||
// Cursor is at the end - check if we have room for the space
|
||||
if (lineVisibleWidth < contentWidth) {
|
||||
// We have room - add highlighted space
|
||||
const cursor = "\x1b[7m \x1b[0m";
|
||||
displayText = before + marker + cursor;
|
||||
// lineVisibleWidth increases by 1 - we're adding a space
|
||||
lineVisibleWidth = lineVisibleWidth + 1;
|
||||
} else {
|
||||
// Line is at full width - use reverse video on last grapheme if possible
|
||||
// or just show cursor at the end without adding space
|
||||
const beforeGraphemes = [...segmenter.segment(before)];
|
||||
if (beforeGraphemes.length > 0) {
|
||||
const lastGrapheme = beforeGraphemes[beforeGraphemes.length - 1]?.segment || "";
|
||||
const cursor = `\x1b[7m${lastGrapheme}\x1b[0m`;
|
||||
// Rebuild 'before' without the last grapheme
|
||||
const beforeWithoutLast = beforeGraphemes
|
||||
.slice(0, -1)
|
||||
.map((g) => g.segment)
|
||||
.join("");
|
||||
displayText = beforeWithoutLast + marker + cursor;
|
||||
}
|
||||
// lineVisibleWidth stays the same
|
||||
// Cursor is at the end - add highlighted space
|
||||
const cursor = "\x1b[7m \x1b[0m";
|
||||
displayText = before + marker + cursor;
|
||||
lineVisibleWidth = lineVisibleWidth + 1;
|
||||
// If cursor overflows content width into the padding, flag it
|
||||
if (lineVisibleWidth > contentWidth && paddingX > 0) {
|
||||
cursorInPadding = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate padding based on actual visible width
|
||||
const padding = " ".repeat(Math.max(0, contentWidth - lineVisibleWidth));
|
||||
const lineRightPadding = cursorInPadding ? rightPadding.slice(1) : rightPadding;
|
||||
|
||||
// Render the line (no side borders, just horizontal lines above and below)
|
||||
result.push(`${leftPadding}${displayText}${padding}${rightPadding}`);
|
||||
result.push(`${leftPadding}${displayText}${padding}${lineRightPadding}`);
|
||||
}
|
||||
|
||||
// Render bottom border (with scroll indicator if more content below)
|
||||
|
|
|
|||
|
|
@ -539,7 +539,7 @@ describe("Editor component", () => {
|
|||
|
||||
it("wraps CJK characters correctly (each is 2 columns wide)", () => {
|
||||
const editor = new Editor(createTestTUI(), defaultEditorTheme);
|
||||
const width = 10;
|
||||
const width = 10 + 1; // +1 col reserved for cursor
|
||||
|
||||
// Each CJK char is 2 columns. "日本語テスト" = 6 chars = 12 columns
|
||||
editor.setText("日本語テスト");
|
||||
|
|
@ -559,9 +559,9 @@ describe("Editor component", () => {
|
|||
|
||||
it("handles mixed ASCII and wide characters in wrapping", () => {
|
||||
const editor = new Editor(createTestTUI(), defaultEditorTheme);
|
||||
const width = 15;
|
||||
const width = 15 + 1; // +1 col reserved for cursor
|
||||
|
||||
// "Test ✅ OK 日本" = 4 + 1 + 2 + 1 + 2 + 1 + 4 = 15 columns (fits exactly)
|
||||
// "Test ✅ OK 日本" = 4 + 1 + 2 + 1 + 2 + 1 + 4 = 15 columns (fits in width-1=15)
|
||||
editor.setText("Test ✅ OK 日本");
|
||||
const lines = editor.render(width);
|
||||
|
||||
|
|
@ -603,6 +603,26 @@ describe("Editor component", () => {
|
|||
assert.ok(lineWidth <= width, `Line ${i} has width ${lineWidth}, exceeds max ${width}`);
|
||||
}
|
||||
});
|
||||
|
||||
it("shows cursor at end of line before wrap, wraps on next char", () => {
|
||||
const width = 10;
|
||||
for (const paddingX of [0, 1]) {
|
||||
const editor = new Editor(createTestTUI(width + paddingX), defaultEditorTheme, { paddingX });
|
||||
|
||||
// Type 9 chars → fills layoutWidth exactly, cursor at end on same line
|
||||
for (const ch of "aaaaaaaaa") editor.handleInput(ch);
|
||||
let lines = editor.render(width + paddingX);
|
||||
let contentLines = lines.slice(1, -1);
|
||||
assert.strictEqual(contentLines.length, 1, "Should be 1 content line before wrap");
|
||||
assert.ok(contentLines[0]!.endsWith("\x1b[7m \x1b[0m"), "Cursor should be at end of line");
|
||||
|
||||
// Type 1 more → text wraps to second line
|
||||
editor.handleInput("a");
|
||||
lines = editor.render(width + paddingX);
|
||||
contentLines = lines.slice(1, -1);
|
||||
assert.strictEqual(contentLines.length, 2, "Should wrap to 2 content lines");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("Word wrapping", () => {
|
||||
|
|
@ -688,7 +708,7 @@ describe("Editor component", () => {
|
|||
|
||||
it("handles single word that fits exactly", () => {
|
||||
const editor = new Editor(createTestTUI(), defaultEditorTheme);
|
||||
const width = 10;
|
||||
const width = 10 + 1; // +1 col reserved for cursor
|
||||
|
||||
editor.setText("1234567890");
|
||||
const lines = editor.render(width);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue