Sfoglia il codice sorgente

fix(ui/chat): word-wrap text instead of breaking mid-word

wrapStyledLine now tracks word boundaries and breaks lines at spaces
rather than splitting words across lines. Single words longer than the
viewport width still hard-break as a fallback.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
claude 1 mese fa
parent
commit
e2c1fa7959
1 ha cambiato i file con 54 aggiunte e 11 eliminazioni
  1. 54 11
      internal/ui/chat/embed_renderer.go

+ 54 - 11
internal/ui/chat/embed_renderer.go

@@ -179,15 +179,33 @@ func wrapStyledLine(line tview.Line, width int) []tview.Line {
 	current := make(tview.Line, 0, len(line))
 	currentWidth := 0
 
-	pushSegment := func(text string, style tcell.Style) {
+	// Word-wrap state: track the current word so we can break before it.
+	wordLine := make(tview.Line, 0, 4) // segments in the current word
+	wordWidth := 0
+	lineBeforeWord := make(tview.Line, 0, len(line)) // line content before the current word
+	lineBeforeWordWidth := 0
+
+	pushSegment := func(dst *tview.Line, text string, style tcell.Style) {
 		if text == "" {
 			return
 		}
-		if n := len(current); n > 0 && current[n-1].Style == style {
-			current[n-1].Text += text
+		if n := len(*dst); n > 0 && (*dst)[n-1].Style == style {
+			(*dst)[n-1].Text += text
 			return
 		}
-		current = append(current, tview.Segment{Text: text, Style: style})
+		*dst = append(*dst, tview.Segment{Text: text, Style: style})
+	}
+
+	commitWord := func() {
+		for _, seg := range wordLine {
+			pushSegment(&current, seg.Text, seg.Style)
+		}
+		currentWidth += wordWidth
+		// Save snapshot as potential break point.
+		lineBeforeWord = append(lineBeforeWord[:0], current...)
+		lineBeforeWordWidth = currentWidth
+		wordLine = wordLine[:0]
+		wordWidth = 0
 	}
 
 	flush := func() {
@@ -196,6 +214,8 @@ func wrapStyledLine(line tview.Line, width int) []tview.Line {
 		lines = append(lines, lineCopy)
 		current = current[:0]
 		currentWidth = 0
+		lineBeforeWord = lineBeforeWord[:0]
+		lineBeforeWordWidth = 0
 	}
 
 	for _, segment := range line {
@@ -209,20 +229,43 @@ func wrapStyledLine(line tview.Line, width int) []tview.Line {
 				continue
 			}
 
-			// Use grapheme width (not rune count) so wrapping stays correct with wide glyphs, emoji, and combining characters.
 			clusterWidth := graphemeClusterWidth(boundaries)
-			if currentWidth > 0 && currentWidth+clusterWidth > width {
-				flush()
+			isSpace := cluster == " " || cluster == "\t"
+
+			if isSpace {
+				// Space: commit pending word, then add the space to the line.
+				commitWord()
+				if currentWidth+clusterWidth > width {
+					flush()
+				} else {
+					pushSegment(&current, cluster, segment.Style)
+					currentWidth += clusterWidth
+					lineBeforeWord = append(lineBeforeWord[:0], current...)
+					lineBeforeWordWidth = currentWidth
+				}
+				continue
 			}
-			pushSegment(cluster, segment.Style)
-			currentWidth += clusterWidth
 
-			if currentWidth >= width {
-				flush()
+			// Non-space: add to current word.
+			if currentWidth+wordWidth+clusterWidth > width {
+				if wordWidth > 0 && lineBeforeWordWidth > 0 {
+					// Break before the current word: rewind to lineBeforeWord.
+					current = append(current[:0], lineBeforeWord...)
+					currentWidth = lineBeforeWordWidth
+					flush()
+				} else if currentWidth > 0 || wordWidth > 0 {
+					// Word is at start of line or single long word: commit what we have and flush.
+					commitWord()
+					flush()
+				}
 			}
+			pushSegment(&wordLine, cluster, segment.Style)
+			wordWidth += clusterWidth
 		}
 	}
 
+	// Flush remaining word and line.
+	commitWord()
 	if len(current) > 0 {
 		flush()
 	}