From dde5f25170321edbc756febee3ea0e763fa340b1 Mon Sep 17 00:00:00 2001 From: Nick Seelert Date: Tue, 30 Dec 2025 23:05:35 -0500 Subject: [PATCH] feat(tui): implement word wrapping in Editor component Previously, the Editor component used character-level (grapheme-level) wrapping, which broke words mid-character at line boundaries. This created an ugly visual experience when typing or pasting long text. Now the Editor uses word-aware wrapping: - Wraps at word boundaries when possible - Falls back to character-level wrapping for tokens wider than the available width (e.g., long URLs) - Strips leading whitespace at line starts - Preserves multiple spaces within lines Added wordWrapLine() helper function that tokenizes text into words and whitespace runs, then builds chunks that fit within the specified width while respecting word boundaries. Also updated buildVisualLineMap() to use the same word wrapping logic for consistent cursor navigation. Added tests for: - Word boundary wrapping - Leading whitespace stripping - Long token (URL) character-level fallback - Multiple space preservation - Edge cases (empty string, exact fit) --- packages/tui/CHANGELOG.md | 1 + packages/tui/src/components/editor.ts | 283 +++++++++++++++++++------- packages/tui/test/editor.test.ts | 95 +++++++++ 3 files changed, 309 insertions(+), 70 deletions(-) diff --git a/packages/tui/CHANGELOG.md b/packages/tui/CHANGELOG.md index d9ba7270..4721ba30 100644 --- a/packages/tui/CHANGELOG.md +++ b/packages/tui/CHANGELOG.md @@ -12,6 +12,7 @@ ### Changed - README.md completely rewritten with accurate component documentation, theme interfaces, and examples +- Editor component now uses word wrapping instead of character-level wrapping for better readability ### Fixed diff --git a/packages/tui/src/components/editor.ts b/packages/tui/src/components/editor.ts index 017cb1c4..8f2bd7bb 100644 --- a/packages/tui/src/components/editor.ts +++ b/packages/tui/src/components/editor.ts @@ -31,6 +31,186 @@ import { SelectList, type SelectListTheme } from "./select-list.js"; const segmenter = getSegmenter(); +/** + * Represents a chunk of text for word-wrap layout. + * Tracks both the text content and its position in the original line. + */ +interface TextChunk { + text: string; + startIndex: number; + endIndex: number; +} + +/** + * Split a line into word-wrapped chunks. + * Wraps at word boundaries when possible, falling back to character-level + * wrapping for words longer than the available width. + * + * @param line - The text line to wrap + * @param maxWidth - Maximum visible width per chunk + * @returns Array of chunks with text and position information + */ +function wordWrapLine(line: string, maxWidth: number): TextChunk[] { + if (!line || maxWidth <= 0) { + return [{ text: "", startIndex: 0, endIndex: 0 }]; + } + + const lineWidth = visibleWidth(line); + if (lineWidth <= maxWidth) { + return [{ text: line, startIndex: 0, endIndex: line.length }]; + } + + const chunks: TextChunk[] = []; + + // Split into tokens (words and whitespace runs) + const tokens: { text: string; startIndex: number; endIndex: number; isWhitespace: boolean }[] = []; + let currentToken = ""; + let tokenStart = 0; + let inWhitespace = false; + let charIndex = 0; + + for (const seg of segmenter.segment(line)) { + const grapheme = seg.segment; + const graphemeIsWhitespace = isWhitespaceChar(grapheme); + + if (currentToken === "") { + inWhitespace = graphemeIsWhitespace; + tokenStart = charIndex; + } else if (graphemeIsWhitespace !== inWhitespace) { + // Token type changed - save current token + tokens.push({ + text: currentToken, + startIndex: tokenStart, + endIndex: charIndex, + isWhitespace: inWhitespace, + }); + currentToken = ""; + tokenStart = charIndex; + inWhitespace = graphemeIsWhitespace; + } + + currentToken += grapheme; + charIndex += grapheme.length; + } + + // Push final token + if (currentToken) { + tokens.push({ + text: currentToken, + startIndex: tokenStart, + endIndex: charIndex, + isWhitespace: inWhitespace, + }); + } + + // Build chunks using word wrapping + let currentChunk = ""; + let currentWidth = 0; + let chunkStartIndex = 0; + let atLineStart = true; // Track if we're at the start of a line (for skipping whitespace) + + for (const token of tokens) { + const tokenWidth = visibleWidth(token.text); + + // Skip leading whitespace at line start + if (atLineStart && token.isWhitespace) { + chunkStartIndex = token.endIndex; + continue; + } + atLineStart = false; + + // If this single token is wider than maxWidth, we need to break it + if (tokenWidth > maxWidth) { + // First, push any accumulated chunk + if (currentChunk) { + chunks.push({ + text: currentChunk, + startIndex: chunkStartIndex, + endIndex: token.startIndex, + }); + currentChunk = ""; + currentWidth = 0; + chunkStartIndex = token.startIndex; + } + + // Break the long token by grapheme + let tokenChunk = ""; + let tokenChunkWidth = 0; + let tokenChunkStart = token.startIndex; + let tokenCharIndex = token.startIndex; + + for (const seg of segmenter.segment(token.text)) { + const grapheme = seg.segment; + const graphemeWidth = visibleWidth(grapheme); + + if (tokenChunkWidth + graphemeWidth > maxWidth && tokenChunk) { + chunks.push({ + text: tokenChunk, + startIndex: tokenChunkStart, + endIndex: tokenCharIndex, + }); + tokenChunk = grapheme; + tokenChunkWidth = graphemeWidth; + tokenChunkStart = tokenCharIndex; + } else { + tokenChunk += grapheme; + tokenChunkWidth += graphemeWidth; + } + tokenCharIndex += grapheme.length; + } + + // Keep remainder as start of next chunk + if (tokenChunk) { + currentChunk = tokenChunk; + currentWidth = tokenChunkWidth; + chunkStartIndex = tokenChunkStart; + } + continue; + } + + // Check if adding this token would exceed width + if (currentWidth + tokenWidth > maxWidth) { + // Push current chunk (trimming trailing whitespace for display) + const trimmedChunk = currentChunk.trimEnd(); + if (trimmedChunk || chunks.length === 0) { + chunks.push({ + text: trimmedChunk, + startIndex: chunkStartIndex, + endIndex: chunkStartIndex + currentChunk.length, + }); + } + + // Start new line - skip leading whitespace + atLineStart = true; + if (token.isWhitespace) { + currentChunk = ""; + currentWidth = 0; + chunkStartIndex = token.endIndex; + } else { + currentChunk = token.text; + currentWidth = tokenWidth; + chunkStartIndex = token.startIndex; + atLineStart = false; + } + } else { + // Add token to current chunk + currentChunk += token.text; + currentWidth += tokenWidth; + } + } + + // Push final chunk + if (currentChunk) { + chunks.push({ + text: currentChunk, + startIndex: chunkStartIndex, + endIndex: line.length, + }); + } + + return chunks.length > 0 ? chunks : [{ text: "", startIndex: 0, endIndex: 0 }]; +} + interface EditorState { lines: string[]; cursorLine: number; @@ -543,42 +723,8 @@ export class Editor implements Component { }); } } else { - // Line needs wrapping - use grapheme-aware chunking - const chunks: { text: string; startIndex: number; endIndex: number }[] = []; - let currentChunk = ""; - let currentWidth = 0; - let chunkStartIndex = 0; - let currentIndex = 0; - - for (const seg of segmenter.segment(line)) { - const grapheme = seg.segment; - const graphemeWidth = visibleWidth(grapheme); - - if (currentWidth + graphemeWidth > contentWidth && currentChunk !== "") { - // Start a new chunk - chunks.push({ - text: currentChunk, - startIndex: chunkStartIndex, - endIndex: currentIndex, - }); - currentChunk = grapheme; - currentWidth = graphemeWidth; - chunkStartIndex = currentIndex; - } else { - currentChunk += grapheme; - currentWidth += graphemeWidth; - } - currentIndex += grapheme.length; - } - - // Push the last chunk - if (currentChunk !== "") { - chunks.push({ - text: currentChunk, - startIndex: chunkStartIndex, - endIndex: currentIndex, - }); - } + // Line needs wrapping - use word-aware wrapping + const chunks = wordWrapLine(line, contentWidth); for (let chunkIndex = 0; chunkIndex < chunks.length; chunkIndex++) { const chunk = chunks[chunkIndex]; @@ -586,17 +732,37 @@ export class Editor implements Component { const cursorPos = this.state.cursorCol; const isLastChunk = chunkIndex === chunks.length - 1; - // For non-last chunks, cursor at endIndex belongs to the next chunk - const hasCursorInChunk = - isCurrentLine && - cursorPos >= chunk.startIndex && - (isLastChunk ? cursorPos <= chunk.endIndex : cursorPos < chunk.endIndex); + + // Determine if cursor is in this chunk + // For word-wrapped chunks, we need to handle the case where + // cursor might be in trimmed whitespace at end of chunk + let hasCursorInChunk = false; + let adjustedCursorPos = 0; + + if (isCurrentLine) { + if (isLastChunk) { + // Last chunk: cursor belongs here if >= startIndex + hasCursorInChunk = cursorPos >= chunk.startIndex; + adjustedCursorPos = cursorPos - chunk.startIndex; + } else { + // Non-last chunk: cursor belongs here if in range [startIndex, endIndex) + // But we need to handle the visual position in the trimmed text + hasCursorInChunk = cursorPos >= chunk.startIndex && cursorPos < chunk.endIndex; + if (hasCursorInChunk) { + adjustedCursorPos = cursorPos - chunk.startIndex; + // Clamp to text length (in case cursor was in trimmed whitespace) + if (adjustedCursorPos > chunk.text.length) { + adjustedCursorPos = chunk.text.length; + } + } + } + } if (hasCursorInChunk) { layoutLines.push({ text: chunk.text, hasCursor: true, - cursorPos: cursorPos - chunk.startIndex, + cursorPos: adjustedCursorPos, }); } else { layoutLines.push({ @@ -997,36 +1163,13 @@ export class Editor implements Component { } else if (lineVisWidth <= width) { visualLines.push({ logicalLine: i, startCol: 0, length: line.length }); } else { - // Line needs wrapping - use grapheme-aware chunking - let currentWidth = 0; - let chunkStartIndex = 0; - let currentIndex = 0; - - for (const seg of segmenter.segment(line)) { - const grapheme = seg.segment; - const graphemeWidth = visibleWidth(grapheme); - - if (currentWidth + graphemeWidth > width && currentIndex > chunkStartIndex) { - // Start a new chunk - visualLines.push({ - logicalLine: i, - startCol: chunkStartIndex, - length: currentIndex - chunkStartIndex, - }); - chunkStartIndex = currentIndex; - currentWidth = graphemeWidth; - } else { - currentWidth += graphemeWidth; - } - currentIndex += grapheme.length; - } - - // Push the last chunk - if (currentIndex > chunkStartIndex) { + // Line needs wrapping - use word-aware wrapping + const chunks = wordWrapLine(line, width); + for (const chunk of chunks) { visualLines.push({ logicalLine: i, - startCol: chunkStartIndex, - length: currentIndex - chunkStartIndex, + startCol: chunk.startIndex, + length: chunk.endIndex - chunk.startIndex, }); } } diff --git a/packages/tui/test/editor.test.ts b/packages/tui/test/editor.test.ts index c77dab7b..b48f2628 100644 --- a/packages/tui/test/editor.test.ts +++ b/packages/tui/test/editor.test.ts @@ -576,4 +576,99 @@ describe("Editor component", () => { } }); }); + + describe("Word wrapping", () => { + it("wraps at word boundaries instead of mid-word", () => { + const editor = new Editor(defaultEditorTheme); + const width = 40; + + editor.setText("Hello world this is a test of word wrapping functionality"); + const lines = editor.render(width); + + // Get content lines (between borders) + const contentLines = lines.slice(1, -1).map((l) => stripVTControlCharacters(l).trim()); + + // Should NOT break mid-word + // Line 1 should end with a complete word + assert.ok(!contentLines[0]!.endsWith("-"), "Line should not end with hyphen (mid-word break)"); + + // Each content line should be complete words + for (const line of contentLines) { + // Words at end of line should be complete (no partial words) + const lastChar = line.trimEnd().slice(-1); + assert.ok(lastChar === "" || /[\w.,!?;:]/.test(lastChar), `Line ends unexpectedly with: "${lastChar}"`); + } + }); + + it("does not start lines with leading whitespace after word wrap", () => { + const editor = new Editor(defaultEditorTheme); + const width = 20; + + editor.setText("Word1 Word2 Word3 Word4 Word5 Word6"); + const lines = editor.render(width); + + // Get content lines (between borders) + const contentLines = lines.slice(1, -1); + + // No line should start with whitespace (except for padding at the end) + for (let i = 0; i < contentLines.length; i++) { + const line = stripVTControlCharacters(contentLines[i]!); + const trimmedStart = line.trimStart(); + // The line should either be all padding or start with a word character + if (trimmedStart.length > 0) { + assert.ok(!/^\s+\S/.test(line.trimEnd()), `Line ${i} starts with unexpected whitespace before content`); + } + } + }); + + it("breaks long words (URLs) at character level", () => { + const editor = new Editor(defaultEditorTheme); + const width = 30; + + editor.setText("Check https://example.com/very/long/path/that/exceeds/width here"); + const lines = editor.render(width); + + // All lines should fit within width + for (let i = 1; i < lines.length - 1; i++) { + const lineWidth = visibleWidth(lines[i]!); + assert.strictEqual(lineWidth, width, `Line ${i} has width ${lineWidth}, expected ${width}`); + } + }); + + it("preserves multiple spaces within words on same line", () => { + const editor = new Editor(defaultEditorTheme); + const width = 50; + + editor.setText("Word1 Word2 Word3"); + const lines = editor.render(width); + + const contentLine = stripVTControlCharacters(lines[1]!).trim(); + // Multiple spaces should be preserved + assert.ok(contentLine.includes("Word1 Word2"), "Multiple spaces should be preserved"); + }); + + it("handles empty string", () => { + const editor = new Editor(defaultEditorTheme); + const width = 40; + + editor.setText(""); + const lines = editor.render(width); + + // Should have border + empty content + border + assert.strictEqual(lines.length, 3); + }); + + it("handles single word that fits exactly", () => { + const editor = new Editor(defaultEditorTheme); + const width = 10; + + editor.setText("1234567890"); + const lines = editor.render(width); + + // Should have exactly 3 lines (top border, content, bottom border) + assert.strictEqual(lines.length, 3); + const contentLine = stripVTControlCharacters(lines[1]!); + assert.ok(contentLine.includes("1234567890"), "Content should contain the word"); + }); + }); });