From a6451ec51a072e9d99a125741939ee3f46fd0dd1 Mon Sep 17 00:00:00 2001 From: Junegunn Choi Date: Sun, 1 Mar 2026 18:46:26 +0900 Subject: [PATCH] Fix nth attr merge order to respect precedence hierarchy nth attrs were merged ON TOP of current-fg/selected-fg attrs, so nth:regular would clear attrs like underline from current-fg. Fix the merge chain to apply nth BEFORE the line-type overlay: fg < nth < selected-fg < current-fg < hl < selected-hl < current-hl Store raw current-fg and selected-fg attrs in ColorTheme before they get merged with fg/ListFg, then pass them as nthOverlay through printHighlighted to colorOffsets where the correct merge chain is computed: fgAttr.Merge(nthAttr).Merge(nthOverlay). Fix #4687 --- src/result.go | 14 +++++++++----- src/result_test.go | 35 +++++++++++++++++++++++++++++++++-- src/terminal.go | 24 +++++++++++++++--------- src/tui/tui.go | 13 ++++++++++++- 4 files changed, 69 insertions(+), 17 deletions(-) diff --git a/src/result.go b/src/result.go index ddf5102f..945e272c 100644 --- a/src/result.go +++ b/src/result.go @@ -128,7 +128,7 @@ func minRank() Result { return Result{item: &minItem, points: [4]uint16{math.MaxUint16, 0, 0, 0}} } -func (result *Result) colorOffsets(matchOffsets []Offset, nthOffsets []Offset, theme *tui.ColorTheme, colBase tui.ColorPair, colMatch tui.ColorPair, attrNth tui.Attr, hidden bool) []colorOffset { +func (result *Result) colorOffsets(matchOffsets []Offset, nthOffsets []Offset, theme *tui.ColorTheme, colBase tui.ColorPair, colMatch tui.ColorPair, attrNth tui.Attr, nthOverlay tui.Attr, hidden bool) []colorOffset { itemColors := result.item.Colors() // No ANSI codes @@ -213,6 +213,10 @@ func (result *Result) colorOffsets(matchOffsets []Offset, nthOffsets []Offset, t } return tui.NewColorPair(fg, bg, ansi.color.attr).WithUl(ansi.color.ul).MergeAttr(base) } + fgAttr := tui.ColNormal.Attr() + nthAttrFinal := fgAttr.Merge(attrNth).Merge(nthOverlay) + nthBase := colBase.WithNewAttr(nthAttrFinal) + var colors []colorOffset add := func(idx int) { if curr.fbg >= 0 { @@ -226,7 +230,7 @@ func (result *Result) colorOffsets(matchOffsets []Offset, nthOffsets []Offset, t if curr.match { var color tui.ColorPair if curr.nth { - color = colBase.WithAttr(attrNth).Merge(colMatch) + color = nthBase.Merge(colMatch) } else { color = colBase.Merge(colMatch) } @@ -246,7 +250,7 @@ func (result *Result) colorOffsets(matchOffsets []Offset, nthOffsets []Offset, t if color.Fg().IsDefault() && origColor.HasBg() { color = origColor if curr.nth { - color = color.WithAttr(attrNth &^ tui.AttrRegular) + color = color.WithAttr((attrNth &^ tui.AttrRegular).Merge(nthOverlay)) } } else { color = origColor.MergeNonDefault(color) @@ -258,7 +262,7 @@ func (result *Result) colorOffsets(matchOffsets []Offset, nthOffsets []Offset, t ansi := itemColors[curr.index] base := colBase if curr.nth { - base = base.WithAttr(attrNth) + base = nthBase } if hidden { base = base.WithFg(theme.Nomatch) @@ -270,7 +274,7 @@ func (result *Result) colorOffsets(matchOffsets []Offset, nthOffsets []Offset, t match: false, url: ansi.color.url}) } else { - color := colBase.WithAttr(attrNth) + color := nthBase if hidden { color = color.WithFg(theme.Nomatch) } diff --git a/src/result_test.go b/src/result_test.go index d8461495..8fd61984 100644 --- a/src/result_test.go +++ b/src/result_test.go @@ -132,7 +132,7 @@ func TestColorOffset(t *testing.T) { colBase := tui.NewColorPair(89, 189, tui.AttrUndefined) colMatch := tui.NewColorPair(99, 199, tui.AttrUndefined) - colors := item.colorOffsets(offsets, nil, tui.Dark256, colBase, colMatch, tui.AttrUndefined, false) + colors := item.colorOffsets(offsets, nil, tui.Dark256, colBase, colMatch, tui.AttrUndefined, 0, false) assert := func(idx int, b int32, e int32, c tui.ColorPair) { o := colors[idx] if o.offset[0] != b || o.offset[1] != e || o.color != c { @@ -159,7 +159,7 @@ func TestColorOffset(t *testing.T) { nthOffsets := []Offset{{37, 39}, {42, 45}} for _, attr := range []tui.Attr{tui.AttrRegular, tui.StrikeThrough} { - colors = item.colorOffsets(offsets, nthOffsets, tui.Dark256, colRegular, colUnderline, attr, false) + colors = item.colorOffsets(offsets, nthOffsets, tui.Dark256, colRegular, colUnderline, attr, 0, false) // [{[0 5] {1 5 0}} {[5 15] {1 5 8}} {[15 20] {1 5 0}} // {[22 25] {2 6 1}} {[25 27] {2 6 9}} {[27 30] {-1 -1 8}} @@ -182,6 +182,37 @@ func TestColorOffset(t *testing.T) { assert(10, 37, 39, tui.NewColorPair(4, 8, expected)) assert(11, 39, 40, tui.NewColorPair(4, 8, tui.Bold)) } + + // Test nthOverlay: simulates nth:regular with current-fg:underline + // The overlay (underline) should survive even though nth:regular clears attrs. + // Precedence: fg < nth < current-fg + colors = item.colorOffsets(offsets, nthOffsets, tui.Dark256, colRegular, colUnderline, tui.AttrRegular, tui.Underline, false) + + // nth regions should have Underline (from overlay), not cleared by AttrRegular + // Non-nth regions keep colBase attrs (AttrUndefined) + assert(0, 0, 5, tui.NewColorPair(1, 5, tui.AttrUndefined)) + assert(1, 5, 15, tui.NewColorPair(1, 5, tui.Underline)) + assert(2, 15, 20, tui.NewColorPair(1, 5, tui.AttrUndefined)) + assert(3, 22, 25, tui.NewColorPair(2, 6, tui.Bold)) + assert(4, 25, 27, tui.NewColorPair(2, 6, tui.Bold|tui.Underline)) + assert(5, 27, 30, colUnderline) + assert(6, 30, 32, tui.NewColorPair(3, 7, tui.Underline)) + assert(7, 32, 33, colUnderline) + assert(8, 33, 35, tui.NewColorPair(4, 8, tui.Bold|tui.Underline)) + assert(9, 35, 37, tui.NewColorPair(4, 8, tui.Bold)) + // nth region within ANSI bold: AttrRegular clears, overlay adds Underline back + assert(10, 37, 39, tui.NewColorPair(4, 8, tui.Bold|tui.Underline)) + assert(11, 39, 40, tui.NewColorPair(4, 8, tui.Bold)) + + // Test nthOverlay with additive attrs: nth:strikethrough with selected-fg:bold + colors = item.colorOffsets(offsets, nthOffsets, tui.Dark256, colRegular, colUnderline, tui.StrikeThrough, tui.Bold, false) + + // Non-nth entries unchanged from overlay=0 case + assert(0, 0, 5, tui.NewColorPair(1, 5, tui.AttrUndefined)) + assert(5, 27, 30, colUnderline) // match only, no nth + assert(7, 32, 33, colUnderline) // match only, no nth + // nth region within ANSI bold: StrikeThrough|Bold merged with ANSI Bold + assert(10, 37, 39, tui.NewColorPair(4, 8, tui.Bold|tui.StrikeThrough)) } func TestRadixSortResults(t *testing.T) { diff --git a/src/terminal.go b/src/terminal.go index 45b1cece..04cbbb34 100644 --- a/src/terminal.go +++ b/src/terminal.go @@ -1554,7 +1554,7 @@ func (t *Terminal) ansiLabelPrinter(str string, color *tui.ColorPair, fill bool) printFn := func(window tui.Window, limit int) { if offsets == nil { // tui.Col* are not initialized until renderer.Init() - offsets = result.colorOffsets(nil, nil, t.theme, *color, *color, t.nthAttr, false) + offsets = result.colorOffsets(nil, nil, t.theme, *color, *color, t.nthAttr, 0, false) } for limit > 0 { if length > limit { @@ -1617,7 +1617,7 @@ func (t *Terminal) parsePrompt(prompt string) (func(), int) { return 1 } t.printHighlighted( - Result{item: item}, tui.ColPrompt, tui.ColPrompt, false, false, false, line, line, true, preTask, nil) + Result{item: item}, tui.ColPrompt, tui.ColPrompt, false, false, false, line, line, true, preTask, nil, 0) }) t.wrap = wrap } @@ -3185,7 +3185,7 @@ func (t *Terminal) printFooter() { func(markerClass) int { t.footerWindow.Print(indent) return indentSize - }, nil) + }, nil, 0) } }) t.wrap = wrap @@ -3269,7 +3269,7 @@ func (t *Terminal) printHeaderImpl(window tui.Window, borderShape tui.BorderShap func(markerClass) int { t.window.Print(indent) return indentSize - }, nil) + }, nil, 0) } t.wrap = wrap } @@ -3507,7 +3507,7 @@ func (t *Terminal) printItem(result Result, line int, maxLine int, index int, cu } return indentSize } - finalLineNum = t.printHighlighted(result, tui.ColCurrent, tui.ColCurrentMatch, true, true, !matched, line, maxLine, forceRedraw, preTask, postTask) + finalLineNum = t.printHighlighted(result, tui.ColCurrent, tui.ColCurrentMatch, true, true, !matched, line, maxLine, forceRedraw, preTask, postTask, t.theme.NthCurrentAttr) } else { preTask := func(marker markerClass) int { w := t.window.Width() - t.pointerLen @@ -3541,7 +3541,11 @@ func (t *Terminal) printItem(result Result, line int, maxLine int, index int, cu base = base.WithBg(altBg) match = match.WithBg(altBg) } - finalLineNum = t.printHighlighted(result, base, match, false, true, !matched, line, maxLine, forceRedraw, preTask, postTask) + var nthOverlay tui.Attr + if selected { + nthOverlay = t.theme.NthSelectedAttr + } + finalLineNum = t.printHighlighted(result, base, match, false, true, !matched, line, maxLine, forceRedraw, preTask, postTask, nthOverlay) } for i := 0; i < t.gap && finalLineNum < maxLine; i++ { finalLineNum++ @@ -3642,7 +3646,7 @@ func (t *Terminal) overflow(runes []rune, max int) bool { return t.displayWidthWithLimit(runes, 0, max) > max } -func (t *Terminal) printHighlighted(result Result, colBase tui.ColorPair, colMatch tui.ColorPair, current bool, match bool, hidden bool, lineNum int, maxLineNum int, forceRedraw bool, preTask func(markerClass) int, postTask func(int, int, bool, bool, tui.ColorPair)) int { +func (t *Terminal) printHighlighted(result Result, colBase tui.ColorPair, colMatch tui.ColorPair, current bool, match bool, hidden bool, lineNum int, maxLineNum int, forceRedraw bool, preTask func(markerClass) int, postTask func(int, int, bool, bool, tui.ColorPair), nthOverlay tui.Attr) int { var displayWidth int item := result.item matchOffsets := []Offset{} @@ -3683,7 +3687,9 @@ func (t *Terminal) printHighlighted(result Result, colBase tui.ColorPair, colMat // But if 'nth' is set to 'regular', it's a sign that you're applying // a different style to the rest of the string. e.g. 'nth:regular,fg:dim' // In this case, we still need to apply it to clear the style. - colBase = colBase.WithAttr(t.nthAttr) + fgAttr := tui.ColNormal.Attr() + nthAttrFinal := fgAttr.Merge(t.nthAttr).Merge(nthOverlay) + colBase = colBase.WithNewAttr(nthAttrFinal) } if !wholeCovered && t.nthAttr > 0 { var tokens []Token @@ -3702,7 +3708,7 @@ func (t *Terminal) printHighlighted(result Result, colBase tui.ColorPair, colMat sort.Sort(ByOrder(nthOffsets)) } } - allOffsets := result.colorOffsets(charOffsets, nthOffsets, t.theme, colBase, colMatch, t.nthAttr, hidden) + allOffsets := result.colorOffsets(charOffsets, nthOffsets, t.theme, colBase, colMatch, t.nthAttr, nthOverlay, hidden) // Determine split offset for horizontal scrolling with freeze splitOffset1 := -1 diff --git a/src/tui/tui.go b/src/tui/tui.go index 4f47b992..8e8068e2 100644 --- a/src/tui/tui.go +++ b/src/tui/tui.go @@ -447,6 +447,12 @@ func (p ColorPair) WithAttr(attr Attr) ColorPair { return dup } +func (p ColorPair) WithNewAttr(attr Attr) ColorPair { + dup := p + dup.attr = attr + return dup +} + func (p ColorPair) WithFg(fg ColorAttr) ColorPair { dup := p fgPair := ColorPair{fg.Color, colUndefined, colUndefined, fg.Attr} @@ -520,6 +526,8 @@ type ColorTheme struct { ListLabel ColorAttr ListBorder ColorAttr GapLine ColorAttr + NthCurrentAttr Attr // raw current-fg attr (before fg merge) for nth overlay + NthSelectedAttr Attr // raw selected-fg attr (before ListFg inherit) for nth overlay } type Event struct { @@ -1205,7 +1213,9 @@ func InitTheme(theme *ColorTheme, baseTheme *ColorTheme, boldify bool, forceBlac if !baseTheme.Colored && current.IsUndefined() { current.Attr |= Reverse } - theme.Current = theme.Fg.Merge(o(baseTheme.Current, current)) + resolvedCurrent := o(baseTheme.Current, current) + theme.NthCurrentAttr = resolvedCurrent.Attr + theme.Current = theme.Fg.Merge(resolvedCurrent) currentMatch := theme.CurrentMatch if !baseTheme.Colored && currentMatch.IsUndefined() { currentMatch.Attr |= Reverse | Underline @@ -1233,6 +1243,7 @@ func InitTheme(theme *ColorTheme, baseTheme *ColorTheme, boldify bool, forceBlac // These colors are not defined in the base themes theme.ListFg = o(theme.Fg, theme.ListFg) theme.ListBg = o(theme.Bg, theme.ListBg) + theme.NthSelectedAttr = theme.SelectedFg.Attr theme.SelectedFg = o(theme.ListFg, theme.SelectedFg) theme.SelectedBg = o(theme.ListBg, theme.SelectedBg) theme.SelectedMatch = o(theme.Match, theme.SelectedMatch)