Skip to content

Commit 362ab26

Browse files
committed
Wrap editor lines to handle large text
1 parent 956c0cb commit 362ab26

1 file changed

Lines changed: 145 additions & 65 deletions

File tree

src/components/TextBuffer.jsx

Lines changed: 145 additions & 65 deletions
Original file line numberDiff line numberDiff line change
@@ -1,59 +1,22 @@
1-
import React, { useState, useEffect, useMemo } from 'react';
2-
import { Box, Text, useInput, measureElement } from 'ink';
1+
import React, { useState, useEffect } from 'react';
2+
import { Box, Text, useInput } from 'ink';
33
import { highlightMarkdownLine } from '../utils/syntaxHighlight.js';
44

55
let lineIdCounter = 0;
66

7-
/**
8-
* Wrap a line of text to fit within maxWidth, breaking at word boundaries
9-
*/
10-
function wrapTextLine(text, maxWidth) {
11-
if (!text || text.length <= maxWidth) return [text];
12-
13-
const wrappedLines = [];
14-
let currentLine = '';
15-
16-
const words = text.split(' ');
17-
18-
for (let i = 0; i < words.length; i++) {
19-
const word = words[i];
20-
const separator = i < words.length - 1 ? ' ' : '';
21-
22-
// If adding this word would exceed max width
23-
if (currentLine.length + word.length + separator.length > maxWidth) {
24-
// If current line is empty, the word itself is too long - split it
25-
if (currentLine.length === 0) {
26-
wrappedLines.push(word.substring(0, maxWidth));
27-
let remaining = word.substring(maxWidth);
28-
while (remaining.length > maxWidth) {
29-
wrappedLines.push(remaining.substring(0, maxWidth));
30-
remaining = remaining.substring(maxWidth);
31-
}
32-
if (remaining.length > 0) {
33-
currentLine = remaining + separator;
34-
}
35-
} else {
36-
// Start a new line with this word
37-
wrappedLines.push(currentLine.trimEnd());
38-
currentLine = word + separator;
39-
}
40-
} else {
41-
currentLine += word + separator;
42-
}
43-
}
44-
45-
if (currentLine.length > 0) {
46-
wrappedLines.push(currentLine.trimEnd());
47-
}
48-
49-
return wrappedLines.length > 0 ? wrappedLines : [text];
50-
}
51-
527
/**
538
* Render a line with cursor, selection, and syntax highlighting
549
* Optimized to batch characters with same styling
5510
*/
56-
function renderLineWithCursorAndSelection(lineText, cursorCol, segments, lineIndex, selection, cursorLine) {
11+
function renderLineWithCursorAndSelection(
12+
lineText,
13+
cursorCol,
14+
segments,
15+
lineIndex,
16+
selection,
17+
cursorLine,
18+
chunkStart = 0
19+
) {
5720
const elements = [];
5821

5922
// Determine if this line has selection
@@ -72,16 +35,27 @@ function renderLineWithCursorAndSelection(lineText, cursorCol, segments, lineInd
7235
{ line: selection.startLine, col: selection.startCol };
7336

7437
if (lineIndex >= start.line && lineIndex <= end.line) {
75-
selStart = lineIndex === start.line ? start.col : 0;
76-
selEnd = lineIndex === end.line ? end.col : lineText.length;
38+
const chunkEnd = chunkStart + lineText.length;
39+
const selectionStart = lineIndex === start.line ? start.col : 0;
40+
const selectionEnd = lineIndex === end.line ? end.col : Number.POSITIVE_INFINITY;
41+
42+
const overlapStart = Math.max(chunkStart, selectionStart);
43+
const overlapEnd = Math.min(chunkEnd, selectionEnd);
44+
45+
if (overlapStart < overlapEnd) {
46+
selStart = overlapStart - chunkStart;
47+
selEnd = overlapEnd - chunkStart;
48+
}
7749
}
7850
}
7951

8052
const isCurrentLine = lineIndex === cursorLine;
53+
const cursorPosInChunk = isCurrentLine ? cursorCol - chunkStart : -1;
54+
const hasCursorInChunk = isCurrentLine && cursorPosInChunk >= 0 && cursorPosInChunk <= lineText.length;
8155
let currentPos = 0;
8256

8357
// If not current line and no selection, just render segments as-is (fast path)
84-
if (!isCurrentLine && selStart === -1) {
58+
if (!hasCursorInChunk && selStart === -1) {
8559
for (let i = 0; i < segments.length; i++) {
8660
const segment = segments[i];
8761
elements.push(
@@ -108,7 +82,7 @@ function renderLineWithCursorAndSelection(lineText, cursorCol, segments, lineInd
10882
const char = segment.text[pos];
10983

11084
const isSelected = selStart !== -1 && globalPos >= selStart && globalPos < selEnd;
111-
const isCursor = isCurrentLine && globalPos === cursorCol;
85+
const isCursor = hasCursorInChunk && globalPos === cursorPosInChunk;
11286

11387
let charStyle = { type: 'normal', color: segment.color, bold: segment.bold, italic: segment.italic };
11488
if (isCursor) charStyle = { type: 'cursor' };
@@ -146,13 +120,92 @@ function renderLineWithCursorAndSelection(lineText, cursorCol, segments, lineInd
146120
}
147121

148122
// Handle cursor at end of line
149-
if (isCurrentLine && cursorCol >= currentPos) {
123+
if (hasCursorInChunk && cursorPosInChunk >= currentPos) {
150124
elements.push(<Text key={keyCounter++} inverse> </Text>);
151125
}
152126

153127
return elements;
154128
}
155129

130+
function sliceSegmentsForRange(segments, start, end) {
131+
const sliced = [];
132+
let currentPos = 0;
133+
134+
for (let i = 0; i < segments.length; i++) {
135+
const segment = segments[i];
136+
const segmentEnd = currentPos + segment.text.length;
137+
138+
if (segmentEnd <= start) {
139+
currentPos = segmentEnd;
140+
continue;
141+
}
142+
143+
if (currentPos >= end) {
144+
break;
145+
}
146+
147+
const sliceStart = Math.max(start, currentPos);
148+
const sliceEnd = Math.min(end, segmentEnd);
149+
const relativeStart = sliceStart - currentPos;
150+
const relativeEnd = sliceEnd - currentPos;
151+
const textSlice = segment.text.slice(relativeStart, relativeEnd);
152+
153+
if (textSlice.length > 0) {
154+
sliced.push({ ...segment, text: textSlice });
155+
}
156+
157+
currentPos = segmentEnd;
158+
}
159+
160+
if (sliced.length === 0) {
161+
sliced.push({ text: ' ', color: undefined });
162+
}
163+
164+
return sliced;
165+
}
166+
167+
function wrapLineWithOffsets(text, maxWidth) {
168+
if (!text) {
169+
return [{ text: '', start: 0, end: 0 }];
170+
}
171+
172+
if (!maxWidth || maxWidth <= 0 || text.length <= maxWidth) {
173+
return [{ text, start: 0, end: text.length }];
174+
}
175+
176+
const wrapped = [];
177+
let start = 0;
178+
179+
while (start < text.length) {
180+
let end = Math.min(start + maxWidth, text.length);
181+
182+
if (end < text.length) {
183+
const lastSpace = text.lastIndexOf(' ', end - 1);
184+
if (lastSpace > start) {
185+
end = lastSpace + 1;
186+
}
187+
}
188+
189+
if (end === start) {
190+
end = Math.min(start + maxWidth, text.length);
191+
}
192+
193+
const segmentText = text.slice(start, end);
194+
if (segmentText.length === 0) {
195+
break;
196+
}
197+
198+
wrapped.push({ text: segmentText, start, end });
199+
start = end;
200+
}
201+
202+
if (wrapped.length === 0) {
203+
return [{ text, start: 0, end: text.length }];
204+
}
205+
206+
return wrapped;
207+
}
208+
156209
export default function TextBuffer({ content, onChange, isFocused = true, viewportHeight = 20, onCursorMove, editorWidth }) {
157210
const [lines, setLines] = useState(() =>
158211
content.split('\n').map((line) => ({ id: `line-${lineIdCounter++}`, text: line }))
@@ -647,6 +700,13 @@ export default function TextBuffer({ content, onChange, isFocused = true, viewpo
647700
const hasMoreAbove = scrollOffset > 0;
648701
const hasMoreBelow = scrollOffset + viewportHeight < lines.length;
649702
const totalLines = lines.length;
703+
const lineNumberWidth = Math.max(3, String(totalLines || 1).length);
704+
const LINE_SEPARATOR_WIDTH = 3; // space + vertical bar + space
705+
const PADDING_WIDTH = 2; // paddingX={1}
706+
const numericEditorWidth = typeof editorWidth === 'number' ? editorWidth : null;
707+
const availableWidth = numericEditorWidth !== null
708+
? Math.max(5, numericEditorWidth - lineNumberWidth - LINE_SEPARATOR_WIDTH - PADDING_WIDTH)
709+
: null;
650710

651711
return (
652712
<Box flexDirection="column" paddingX={1}>
@@ -663,19 +723,39 @@ export default function TextBuffer({ content, onChange, isFocused = true, viewpo
663723
{/* Visible lines */}
664724
{visibleLines.map((line, visibleIndex) => {
665725
const actualIndex = scrollOffset + visibleIndex;
666-
const lineText = line.text || ' ';
667-
668-
// Use the optimized rendering function that handles cursor, selection, and syntax highlighting
669-
const segments = highlightMarkdownLine(lineText);
670-
671-
return (
672-
<Box key={line.id} flexDirection="row">
673-
<Text color="gray">{String(actualIndex + 1).padStart(3, ' ')}</Text>
674-
<Box flexDirection="row">
675-
{renderLineWithCursorAndSelection(lineText, cursorCol, segments, actualIndex, selection, cursorLine)}
726+
const rawLineText = line.text || '';
727+
const segments = highlightMarkdownLine(rawLineText);
728+
729+
const wrappedSegments = availableWidth
730+
? wrapLineWithOffsets(rawLineText, availableWidth)
731+
: [{ text: rawLineText, start: 0, end: rawLineText.length }];
732+
733+
return wrappedSegments.map((wrappedSegment, wrapIndex) => {
734+
const chunkText = wrappedSegment.text;
735+
const chunkStart = wrappedSegment.start;
736+
const chunkEnd = wrappedSegment.end;
737+
const chunkSegments = sliceSegmentsForRange(segments, chunkStart, chunkEnd);
738+
const lineNumberText = wrapIndex === 0
739+
? String(actualIndex + 1).padStart(lineNumberWidth, ' ')
740+
: ' '.repeat(lineNumberWidth);
741+
742+
return (
743+
<Box key={`${line.id}-${wrapIndex}`} flexDirection="row">
744+
<Text color="gray">{lineNumberText}</Text>
745+
<Box flexDirection="row">
746+
{renderLineWithCursorAndSelection(
747+
chunkText,
748+
cursorCol,
749+
chunkSegments,
750+
actualIndex,
751+
selection,
752+
cursorLine,
753+
chunkStart
754+
)}
755+
</Box>
676756
</Box>
677-
</Box>
678-
);
757+
);
758+
});
679759
})}
680760

681761
{/* Scroll indicator - bottom */}

0 commit comments

Comments
 (0)