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' ;
33import { highlightMarkdownLine } from '../utils/syntaxHighlight.js' ;
44
55let 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+
156209export 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