@@ -71,6 +71,10 @@ func InvalidateCaches() {
7171type chromaToken struct {
7272 Text string
7373 Style lipgloss.Style
74+ // Emphasized marks tokens whose underlying text was identified as part
75+ // of a word-level diff. Renderers paint these with a stronger background
76+ // so users can locate the precise edit within a long line.
77+ Emphasized bool
7478}
7579
7680type linePair struct {
@@ -310,18 +314,111 @@ func chromaToLipgloss(tokenType chroma.TokenType, style *chroma.Style) lipgloss.
310314 return lipStyle
311315}
312316
317+ // segRange records the byte extent of a single word-diff segment within the
318+ // full line, plus whether that extent represents a change.
319+ type segRange struct {
320+ start , end int
321+ changed bool
322+ }
323+
324+ // applyWordEmphasis re-tags chroma tokens so that any portion of the token
325+ // text that falls inside a "changed" word-diff segment is split off into its
326+ // own emphasized token. Token boundaries from chroma and from the word-diff
327+ // rarely line up, so each chroma token is sliced against the segment cursor
328+ // to produce correctly emphasized sub-tokens.
329+ func applyWordEmphasis (tokens []chromaToken , segs []wordSegment ) []chromaToken {
330+ if len (segs ) == 0 {
331+ return tokens
332+ }
333+
334+ ranges := make ([]segRange , 0 , len (segs ))
335+ pos := 0
336+ for _ , s := range segs {
337+ ranges = append (ranges , segRange {start : pos , end : pos + len (s .Text ), changed : s .Changed })
338+ pos += len (s .Text )
339+ }
340+
341+ out := make ([]chromaToken , 0 , len (tokens ))
342+ bytePos := 0
343+ for _ , tok := range tokens {
344+ text := tok .Text
345+ tokStart := bytePos
346+ tokEnd := bytePos + len (text )
347+
348+ cursor := tokStart
349+ for cursor < tokEnd {
350+ r := findSegRange (ranges , cursor )
351+ if r == nil {
352+ out = append (out , chromaToken {
353+ Text : text [cursor - tokStart :],
354+ Style : tok .Style ,
355+ })
356+ break
357+ }
358+ end := min (tokEnd , r .end )
359+ sub := text [cursor - tokStart : end - tokStart ]
360+ if sub != "" {
361+ out = append (out , chromaToken {
362+ Text : sub ,
363+ Style : tok .Style ,
364+ Emphasized : r .changed ,
365+ })
366+ }
367+ cursor = end
368+ }
369+ bytePos = tokEnd
370+ }
371+
372+ return out
373+ }
374+
375+ func findSegRange (ranges []segRange , pos int ) * segRange {
376+ for i := range ranges {
377+ if pos >= ranges [i ].start && pos < ranges [i ].end {
378+ return & ranges [i ]
379+ }
380+ }
381+ return nil
382+ }
383+
384+ // emphasisStyleFor returns the per-side emphasis style (with a stronger
385+ // background tint) for the row's diff kind. Returns the unchanged style for
386+ // kinds that should never carry word-level emphasis.
387+ func emphasisStyleFor (kind udiff.OpKind ) lipgloss.Style {
388+ switch kind {
389+ case udiff .Delete :
390+ return styles .DiffRemoveEmphStyle
391+ case udiff .Insert :
392+ return styles .DiffAddEmphStyle
393+ default :
394+ return styles .DiffUnchangedStyle
395+ }
396+ }
397+
313398func renderDiffWithSyntaxHighlight (diff []* udiff.Hunk , filePath string , width int ) string {
314399 var output strings.Builder
315400 contentWidth := width - lineNumWidth
316401
317402 for _ , hunk := range diff {
403+ // Build word-diff lookups for paired delete/insert lines so we can
404+ // emphasize the precise tokens that changed.
405+ wordDiffs := buildLineWordDiffs (hunk .Lines )
406+
318407 oldLineNum := hunk .FromLine
319408 newLineNum := hunk .ToLine
320409
321- for _ , line := range hunk .Lines {
410+ for li , line := range hunk .Lines {
322411 lineNum := getDisplayLineNumber (& line , & oldLineNum , & newLineNum )
323412 content := prepareContent (line .Content )
324413 tokens := syntaxHighlight (content , filePath )
414+ if wd , ok := wordDiffs [li ]; ok {
415+ switch line .Kind {
416+ case udiff .Delete :
417+ tokens = applyWordEmphasis (tokens , wd .old )
418+ case udiff .Insert :
419+ tokens = applyWordEmphasis (tokens , wd .new )
420+ }
421+ }
325422 lineStyle := getLineStyle (line .Kind )
326423 wrappedTokens := wrapTokens (tokens , contentWidth )
327424
@@ -334,7 +431,7 @@ func renderDiffWithSyntaxHighlight(diff []*udiff.Hunk, filePath string, width in
334431 // Use continuation indicator for wrapped lines
335432 lineNumStr = styles .LineNumberStyle .Render (" → " )
336433 }
337- rendered := renderTokensWithStyle (tokenLine , lineStyle )
434+ rendered := renderTokensWithStyle (tokenLine , lineStyle , line . Kind )
338435 padded := padToWidth (rendered , contentWidth , lineStyle )
339436 output .WriteString (lineNumStr + padded + "\n " )
340437 }
@@ -358,8 +455,22 @@ func renderSplitDiffWithSyntaxHighlight(diff []*udiff.Hunk, filePath string, wid
358455
359456 for _ , hunk := range diff {
360457 for _ , pair := range pairDiffLines (hunk .Lines , hunk .FromLine , hunk .ToLine ) {
361- leftLines := renderSplitSide (pair .old , pair .oldLineNum , filePath , contentWidth )
362- rightLines := renderSplitSide (pair .new , pair .newLineNum , filePath , contentWidth )
458+ // Word-diff is only meaningful when both halves are present
459+ // and represent a delete/insert pair. Inputs go through
460+ // prepareContent so the segment byte offsets line up with the
461+ // chroma tokens produced for rendering (which also receive
462+ // tab-expanded content).
463+ var oldSegs , newSegs []wordSegment
464+ if pair .old != nil && pair .new != nil &&
465+ pair .old .Kind == udiff .Delete && pair .new .Kind == udiff .Insert {
466+ oldSegs , newSegs = diffWords (
467+ prepareContent (pair .old .Content ),
468+ prepareContent (pair .new .Content ),
469+ )
470+ }
471+
472+ leftLines := renderSplitSide (pair .old , pair .oldLineNum , filePath , contentWidth , oldSegs )
473+ rightLines := renderSplitSide (pair .new , pair .newLineNum , filePath , contentWidth , newSegs )
363474
364475 // Ensure both sides have the same number of lines for alignment
365476 maxLines := max (len (rightLines ), len (leftLines ))
@@ -383,6 +494,32 @@ func renderSplitDiffWithSyntaxHighlight(diff []*udiff.Hunk, filePath string, wid
383494 return strings .TrimSuffix (output .String (), "\n " )
384495}
385496
497+ // lineWordDiff holds the per-side segment arrays computed for one
498+ // delete/insert pair. It is keyed by the *delete* line index — the matching
499+ // insert sits directly after it.
500+ type lineWordDiff struct {
501+ old []wordSegment
502+ new []wordSegment
503+ }
504+
505+ func buildLineWordDiffs (lines []udiff.Line ) map [int ]lineWordDiff {
506+ out := map [int ]lineWordDiff {}
507+ for i := range len (lines ) - 1 {
508+ if lines [i ].Kind != udiff .Delete || lines [i + 1 ].Kind != udiff .Insert {
509+ continue
510+ }
511+ // Use prepareContent so segment offsets align with the chroma
512+ // tokens (which are produced from the same tab-expanded text).
513+ oldText := prepareContent (lines [i ].Content )
514+ newText := prepareContent (lines [i + 1 ].Content )
515+ oldSegs , newSegs := diffWords (oldText , newText )
516+ wd := lineWordDiff {old : oldSegs , new : newSegs }
517+ out [i ] = wd
518+ out [i + 1 ] = wd
519+ }
520+ return out
521+ }
522+
386523func getDisplayLineNumber (line * udiff.Line , oldLineNum , newLineNum * int ) int {
387524 switch line .Kind {
388525 case udiff .Delete :
@@ -456,7 +593,11 @@ func wrapTokens(tokens []chromaToken, maxWidth int) [][]chromaToken {
456593 fitWidth = runewidth .RuneWidth (r )
457594 }
458595
459- currentLine = append (currentLine , chromaToken {Text : text [:fitLen ], Style : token .Style })
596+ currentLine = append (currentLine , chromaToken {
597+ Text : text [:fitLen ],
598+ Style : token .Style ,
599+ Emphasized : token .Emphasized ,
600+ })
460601 currentWidth += fitWidth
461602 text = text [fitLen :]
462603 }
@@ -473,14 +614,20 @@ func wrapTokens(tokens []chromaToken, maxWidth int) [][]chromaToken {
473614 return lines
474615}
475616
476- // renderSplitSide renders a split side with text wrapping support
477- func renderSplitSide (line * udiff.Line , lineNum int , filePath string , width int ) []string {
617+ // renderSplitSide renders a split side with text wrapping support.
618+ // When wordSegs is non-nil and the line is part of a delete/insert pair, the
619+ // chroma tokens are re-tagged so the changed substrings render with a
620+ // stronger background tint.
621+ func renderSplitSide (line * udiff.Line , lineNum int , filePath string , width int , wordSegs []wordSegment ) []string {
478622 if line == nil {
479623 return []string {renderEmptySplitSide (width )}
480624 }
481625
482626 content := prepareContent (line .Content )
483627 tokens := syntaxHighlight (content , filePath )
628+ if len (wordSegs ) > 0 {
629+ tokens = applyWordEmphasis (tokens , wordSegs )
630+ }
484631 lineStyle := getLineStyle (line .Kind )
485632 wrappedTokens := wrapTokens (tokens , width )
486633
@@ -494,7 +641,7 @@ func renderSplitSide(line *udiff.Line, lineNum int, filePath string, width int)
494641 // Use continuation indicator for wrapped lines
495642 lineNumStr = " → "
496643 }
497- rendered := renderTokensWithStyle (tokenLine , lineStyle )
644+ rendered := renderTokensWithStyle (tokenLine , lineStyle , line . Kind )
498645 padded := padToWidth (rendered , width , lineStyle )
499646 result = append (result , styles .LineNumberStyle .Render (lineNumStr )+ padded )
500647 }
@@ -509,12 +656,21 @@ func renderEmptySplitSide(width int) string {
509656 return styles .LineNumberStyle .Render (lineNumStr ) + emptySpace
510657}
511658
512- func renderTokensWithStyle (tokens []chromaToken , lineStyle lipgloss.Style ) string {
659+ func renderTokensWithStyle (tokens []chromaToken , lineStyle lipgloss.Style , kind udiff. OpKind ) string {
513660 var output strings.Builder
514661
662+ emph := emphasisStyleFor (kind )
515663 for _ , token := range tokens {
516- styledToken := token .Style .Background (lineStyle .GetBackground ())
517- output .WriteString (styledToken .Render (token .Text ))
664+ if token .Emphasized && (kind == udiff .Delete || kind == udiff .Insert ) {
665+ // Keep the chroma foreground so syntax colors carry through into
666+ // the emphasized block, but override the background with the
667+ // stronger emphasis tint and add bold for extra weight.
668+ style := token .Style .Background (emph .GetBackground ()).Bold (true )
669+ output .WriteString (style .Render (token .Text ))
670+ continue
671+ }
672+ style := token .Style .Background (lineStyle .GetBackground ())
673+ output .WriteString (style .Render (token .Text ))
518674 }
519675
520676 return output .String ()
0 commit comments