diff --git a/packages/tui/src/components/editor.ts b/packages/tui/src/components/editor.ts index 521c7c65..8f80e364 100644 --- a/packages/tui/src/components/editor.ts +++ b/packages/tui/src/components/editor.ts @@ -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) diff --git a/packages/tui/test/editor.test.ts b/packages/tui/test/editor.test.ts index 0a589120..7b660894 100644 --- a/packages/tui/test/editor.test.ts +++ b/packages/tui/test/editor.test.ts @@ -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);