(line: string, maxWidth: number, preSegmented?: Intl.SegmentData[])
| 113 | * @returns Array of chunks with text and position information |
| 114 | */ |
| 115 | export function wordWrapLine(line: string, maxWidth: number, preSegmented?: Intl.SegmentData[]): TextChunk[] { |
| 116 | if (!line || maxWidth <= 0) { |
| 117 | return [{ text: "", startIndex: 0, endIndex: 0 }]; |
| 118 | } |
| 119 | |
| 120 | const lineWidth = visibleWidth(line); |
| 121 | if (lineWidth <= maxWidth) { |
| 122 | return [{ text: line, startIndex: 0, endIndex: line.length }]; |
| 123 | } |
| 124 | |
| 125 | const chunks: TextChunk[] = []; |
| 126 | const segments = preSegmented ?? [...graphemeSegmenter.segment(line)]; |
| 127 | |
| 128 | let currentWidth = 0; |
| 129 | let chunkStart = 0; |
| 130 | |
| 131 | // Wrap opportunity: the position after the last whitespace before a non-whitespace |
| 132 | // grapheme, i.e. where a line break is allowed. |
| 133 | let wrapOppIndex = -1; |
| 134 | let wrapOppWidth = 0; |
| 135 | |
| 136 | for (let i = 0; i < segments.length; i++) { |
| 137 | const seg = segments[i]!; |
| 138 | const grapheme = seg.segment; |
| 139 | const gWidth = visibleWidth(grapheme); |
| 140 | const charIndex = seg.index; |
| 141 | const isWs = !isPasteMarker(grapheme) && isWhitespaceChar(grapheme); |
| 142 | |
| 143 | // Overflow check before advancing. |
| 144 | if (currentWidth + gWidth > maxWidth) { |
| 145 | if (wrapOppIndex >= 0 && currentWidth - wrapOppWidth + gWidth <= maxWidth) { |
| 146 | // Backtrack to last wrap opportunity (the remaining content |
| 147 | // plus the current grapheme still fits within maxWidth). |
| 148 | chunks.push({ text: line.slice(chunkStart, wrapOppIndex), startIndex: chunkStart, endIndex: wrapOppIndex }); |
| 149 | chunkStart = wrapOppIndex; |
| 150 | currentWidth -= wrapOppWidth; |
| 151 | } else if (chunkStart < charIndex) { |
| 152 | // No viable wrap opportunity: force-break at current position. |
| 153 | // This also handles the case where backtracking to a word |
| 154 | // boundary wouldn't help because the remaining content plus |
| 155 | // the current grapheme (e.g. a wide character) still exceeds |
| 156 | // maxWidth. |
| 157 | chunks.push({ text: line.slice(chunkStart, charIndex), startIndex: chunkStart, endIndex: charIndex }); |
| 158 | chunkStart = charIndex; |
| 159 | currentWidth = 0; |
| 160 | } |
| 161 | wrapOppIndex = -1; |
| 162 | } |
| 163 | |
| 164 | if (gWidth > maxWidth) { |
| 165 | // Single atomic segment wider than maxWidth (e.g. paste marker |
| 166 | // in a narrow terminal). Re-wrap it at grapheme granularity. |
| 167 | |
| 168 | // The segment remains logically atomic for cursor |
| 169 | // movement / editing — the split is purely visual for word-wrap layout. |
| 170 | const subSegments = [...graphemeSegmenter.segment(grapheme)]; |
| 171 | if (subSegments.length <= 1) { |
| 172 | // An indivisible grapheme wider than maxWidth (e.g. a CJK |
no test coverage detected