From 362ab268e9fc3a2f69112c6f8abe95b8fd086757 Mon Sep 17 00:00:00 2001 From: Michael Pfeiffer Date: Thu, 16 Oct 2025 23:05:21 -0500 Subject: [PATCH] Wrap editor lines to handle large text --- src/components/TextBuffer.jsx | 210 +++++++++++++++++++++++----------- 1 file changed, 145 insertions(+), 65 deletions(-) diff --git a/src/components/TextBuffer.jsx b/src/components/TextBuffer.jsx index 35541fe..e0128ad 100644 --- a/src/components/TextBuffer.jsx +++ b/src/components/TextBuffer.jsx @@ -1,59 +1,22 @@ -import React, { useState, useEffect, useMemo } from 'react'; -import { Box, Text, useInput, measureElement } from 'ink'; +import React, { useState, useEffect } from 'react'; +import { Box, Text, useInput } from 'ink'; import { highlightMarkdownLine } from '../utils/syntaxHighlight.js'; let lineIdCounter = 0; -/** - * Wrap a line of text to fit within maxWidth, breaking at word boundaries - */ -function wrapTextLine(text, maxWidth) { - if (!text || text.length <= maxWidth) return [text]; - - const wrappedLines = []; - let currentLine = ''; - - const words = text.split(' '); - - for (let i = 0; i < words.length; i++) { - const word = words[i]; - const separator = i < words.length - 1 ? ' ' : ''; - - // If adding this word would exceed max width - if (currentLine.length + word.length + separator.length > maxWidth) { - // If current line is empty, the word itself is too long - split it - if (currentLine.length === 0) { - wrappedLines.push(word.substring(0, maxWidth)); - let remaining = word.substring(maxWidth); - while (remaining.length > maxWidth) { - wrappedLines.push(remaining.substring(0, maxWidth)); - remaining = remaining.substring(maxWidth); - } - if (remaining.length > 0) { - currentLine = remaining + separator; - } - } else { - // Start a new line with this word - wrappedLines.push(currentLine.trimEnd()); - currentLine = word + separator; - } - } else { - currentLine += word + separator; - } - } - - if (currentLine.length > 0) { - wrappedLines.push(currentLine.trimEnd()); - } - - return wrappedLines.length > 0 ? wrappedLines : [text]; -} - /** * Render a line with cursor, selection, and syntax highlighting * Optimized to batch characters with same styling */ -function renderLineWithCursorAndSelection(lineText, cursorCol, segments, lineIndex, selection, cursorLine) { +function renderLineWithCursorAndSelection( + lineText, + cursorCol, + segments, + lineIndex, + selection, + cursorLine, + chunkStart = 0 +) { const elements = []; // Determine if this line has selection @@ -72,16 +35,27 @@ function renderLineWithCursorAndSelection(lineText, cursorCol, segments, lineInd { line: selection.startLine, col: selection.startCol }; if (lineIndex >= start.line && lineIndex <= end.line) { - selStart = lineIndex === start.line ? start.col : 0; - selEnd = lineIndex === end.line ? end.col : lineText.length; + const chunkEnd = chunkStart + lineText.length; + const selectionStart = lineIndex === start.line ? start.col : 0; + const selectionEnd = lineIndex === end.line ? end.col : Number.POSITIVE_INFINITY; + + const overlapStart = Math.max(chunkStart, selectionStart); + const overlapEnd = Math.min(chunkEnd, selectionEnd); + + if (overlapStart < overlapEnd) { + selStart = overlapStart - chunkStart; + selEnd = overlapEnd - chunkStart; + } } } const isCurrentLine = lineIndex === cursorLine; + const cursorPosInChunk = isCurrentLine ? cursorCol - chunkStart : -1; + const hasCursorInChunk = isCurrentLine && cursorPosInChunk >= 0 && cursorPosInChunk <= lineText.length; let currentPos = 0; // If not current line and no selection, just render segments as-is (fast path) - if (!isCurrentLine && selStart === -1) { + if (!hasCursorInChunk && selStart === -1) { for (let i = 0; i < segments.length; i++) { const segment = segments[i]; elements.push( @@ -108,7 +82,7 @@ function renderLineWithCursorAndSelection(lineText, cursorCol, segments, lineInd const char = segment.text[pos]; const isSelected = selStart !== -1 && globalPos >= selStart && globalPos < selEnd; - const isCursor = isCurrentLine && globalPos === cursorCol; + const isCursor = hasCursorInChunk && globalPos === cursorPosInChunk; let charStyle = { type: 'normal', color: segment.color, bold: segment.bold, italic: segment.italic }; if (isCursor) charStyle = { type: 'cursor' }; @@ -146,13 +120,92 @@ function renderLineWithCursorAndSelection(lineText, cursorCol, segments, lineInd } // Handle cursor at end of line - if (isCurrentLine && cursorCol >= currentPos) { + if (hasCursorInChunk && cursorPosInChunk >= currentPos) { elements.push( ); } return elements; } +function sliceSegmentsForRange(segments, start, end) { + const sliced = []; + let currentPos = 0; + + for (let i = 0; i < segments.length; i++) { + const segment = segments[i]; + const segmentEnd = currentPos + segment.text.length; + + if (segmentEnd <= start) { + currentPos = segmentEnd; + continue; + } + + if (currentPos >= end) { + break; + } + + const sliceStart = Math.max(start, currentPos); + const sliceEnd = Math.min(end, segmentEnd); + const relativeStart = sliceStart - currentPos; + const relativeEnd = sliceEnd - currentPos; + const textSlice = segment.text.slice(relativeStart, relativeEnd); + + if (textSlice.length > 0) { + sliced.push({ ...segment, text: textSlice }); + } + + currentPos = segmentEnd; + } + + if (sliced.length === 0) { + sliced.push({ text: ' ', color: undefined }); + } + + return sliced; +} + +function wrapLineWithOffsets(text, maxWidth) { + if (!text) { + return [{ text: '', start: 0, end: 0 }]; + } + + if (!maxWidth || maxWidth <= 0 || text.length <= maxWidth) { + return [{ text, start: 0, end: text.length }]; + } + + const wrapped = []; + let start = 0; + + while (start < text.length) { + let end = Math.min(start + maxWidth, text.length); + + if (end < text.length) { + const lastSpace = text.lastIndexOf(' ', end - 1); + if (lastSpace > start) { + end = lastSpace + 1; + } + } + + if (end === start) { + end = Math.min(start + maxWidth, text.length); + } + + const segmentText = text.slice(start, end); + if (segmentText.length === 0) { + break; + } + + wrapped.push({ text: segmentText, start, end }); + start = end; + } + + if (wrapped.length === 0) { + return [{ text, start: 0, end: text.length }]; + } + + return wrapped; +} + export default function TextBuffer({ content, onChange, isFocused = true, viewportHeight = 20, onCursorMove, editorWidth }) { const [lines, setLines] = useState(() => content.split('\n').map((line) => ({ id: `line-${lineIdCounter++}`, text: line })) @@ -647,6 +700,13 @@ export default function TextBuffer({ content, onChange, isFocused = true, viewpo const hasMoreAbove = scrollOffset > 0; const hasMoreBelow = scrollOffset + viewportHeight < lines.length; const totalLines = lines.length; + const lineNumberWidth = Math.max(3, String(totalLines || 1).length); + const LINE_SEPARATOR_WIDTH = 3; // space + vertical bar + space + const PADDING_WIDTH = 2; // paddingX={1} + const numericEditorWidth = typeof editorWidth === 'number' ? editorWidth : null; + const availableWidth = numericEditorWidth !== null + ? Math.max(5, numericEditorWidth - lineNumberWidth - LINE_SEPARATOR_WIDTH - PADDING_WIDTH) + : null; return ( @@ -663,19 +723,39 @@ export default function TextBuffer({ content, onChange, isFocused = true, viewpo {/* Visible lines */} {visibleLines.map((line, visibleIndex) => { const actualIndex = scrollOffset + visibleIndex; - const lineText = line.text || ' '; - - // Use the optimized rendering function that handles cursor, selection, and syntax highlighting - const segments = highlightMarkdownLine(lineText); - - return ( - - {String(actualIndex + 1).padStart(3, ' ')} │ - - {renderLineWithCursorAndSelection(lineText, cursorCol, segments, actualIndex, selection, cursorLine)} + const rawLineText = line.text || ''; + const segments = highlightMarkdownLine(rawLineText); + + const wrappedSegments = availableWidth + ? wrapLineWithOffsets(rawLineText, availableWidth) + : [{ text: rawLineText, start: 0, end: rawLineText.length }]; + + return wrappedSegments.map((wrappedSegment, wrapIndex) => { + const chunkText = wrappedSegment.text; + const chunkStart = wrappedSegment.start; + const chunkEnd = wrappedSegment.end; + const chunkSegments = sliceSegmentsForRange(segments, chunkStart, chunkEnd); + const lineNumberText = wrapIndex === 0 + ? String(actualIndex + 1).padStart(lineNumberWidth, ' ') + : ' '.repeat(lineNumberWidth); + + return ( + + {lineNumberText} │ + + {renderLineWithCursorAndSelection( + chunkText, + cursorCol, + chunkSegments, + actualIndex, + selection, + cursorLine, + chunkStart + )} + - - ); + ); + }); })} {/* Scroll indicator - bottom */}