diff --git a/app/main.go b/app/main.go index a5176784..2d9cb1c9 100644 --- a/app/main.go +++ b/app/main.go @@ -6,7 +6,9 @@ import ( "os" tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" "github.com/jessevdk/go-flags" + "github.com/muesli/termenv" "github.com/umputun/revdiff/app/annotation" "github.com/umputun/revdiff/app/diff" @@ -83,6 +85,17 @@ func main() { } func run(opts options) error { + // force lipgloss to truecolor when colors are enabled. revdiff's raw-ANSI + // helpers (style.ansiColor) always emit truecolor, but lipgloss respects + // the termenv-detected profile, which can downgrade to ANSI256 / ANSI in + // tmux or terminals where TERM/COLORTERM detection regresses. The mismatch + // makes lipgloss-rendered colors (pane borders, file tree fg) look wrong + // while raw-ANSI paths (line prefix wrap, overlay title injection) render + // correctly. Forcing truecolor unifies the two paths. + if !opts.NoColors { + lipgloss.SetColorProfile(termenv.TrueColor) + } + store := annotation.NewStore() hl := highlight.New(opts.ChromaStyle, !opts.NoColors) keysPath := opts.Keys diff --git a/app/ui/collapsed.go b/app/ui/collapsed.go index 2d146163..8d9003c6 100644 --- a/app/ui/collapsed.go +++ b/app/ui/collapsed.go @@ -105,8 +105,17 @@ func (m Model) renderCollapsedAddLine(b *strings.Builder, idx int, dl diff.DiffL numGutter, blGutter := m.lineGutters(dl) bgColor := m.resolver.Color(style.ColorKeyAddLineBg) + prefixFg := m.resolver.LineFg(diff.ChangeAdd) if modified { bgColor = m.resolver.Color(style.ColorKeyModifyLineBg) + prefixFg = m.resolver.Color(style.ColorKeyModifyLineFg) + } + if isSearchMatch { + // match the non-highlighted search-match path which uses the search-match + // lipgloss style (fg + bg). when chroma highlighting is on, lineHlStyle + // drops the foreground for chroma to own content fg, so the prefix wrap + // must inject search-fg explicitly. + prefixFg = m.resolver.Color(style.ColorKeySearchFg) } // wrap mode: break long lines at word boundaries with continuation markers @@ -115,13 +124,14 @@ func (m Model) renderCollapsedAddLine(b *strings.Builder, idx int, dl diff.DiffL gutter: gutter, numGutter: numGutter, blGutter: blGutter, isCursor: isCursor, hasHighlight: hasHighlight, lineStyle: lineStyle, hlStyle: lineHlStyle, bgColor: bgColor, + prefixFg: prefixFg, }) return } content := lineStyle.Render(gutter + lineContent) if hasHighlight { - content = lineHlStyle.Render(gutter + textContent) + content = lineHlStyle.Render(m.wrapPrefixForHighlight(gutter, prefixFg, true) + textContent) } content = m.applyHorizontalScroll(content, bgColor) content = m.extendLineBg(content, bgColor) @@ -139,7 +149,7 @@ type wrappedLineCtx struct { gutter, numGutter, blGutter string isCursor, hasHighlight bool lineStyle, hlStyle lipgloss.Style - bgColor style.Color + bgColor, prefixFg style.Color } // renderWrappedCollapsedLine renders a collapsed add line with word wrapping, producing continuation lines with ↪ markers. @@ -158,7 +168,7 @@ func (m Model) renderWrappedCollapsedLine(b *strings.Builder, textContent string var styled string if ctx.hasHighlight { - styled = ctx.hlStyle.Render(prefix + vl) + styled = ctx.hlStyle.Render(m.wrapPrefixForHighlight(prefix, ctx.prefixFg, true) + vl) } else { styled = ctx.lineStyle.Render(prefix + vl) } diff --git a/app/ui/diffview.go b/app/ui/diffview.go index 236df8c8..6b33c034 100644 --- a/app/ui/diffview.go +++ b/app/ui/diffview.go @@ -491,6 +491,18 @@ func (m Model) linePrefix(changeType diff.ChangeType) string { } } +// wrapPrefixForHighlight wraps the +/-/~ prefix in explicit raw ANSI fg when +// chroma highlighting is on. Highlighted line styles intentionally set only +// background (chroma owns per-token fg for content), so the prefix would +// otherwise inherit the terminal default fg and may render invisibly on +// light theme backgrounds. +func (m Model) wrapPrefixForHighlight(prefix string, fg style.Color, hasHighlight bool) string { + if !hasHighlight || fg == "" { + return prefix + } + return string(fg) + prefix + string(style.ResetFg) +} + // highlightSearchMatches wraps each occurrence of the search term in the visible text // with ANSI background color sequence (preserving syntax foreground within matches). // works with both plain text and ANSI-coded content by stripping ANSI to find match positions. @@ -549,7 +561,7 @@ func (m Model) styleDiffContent(changeType diff.ChangeType, prefix, content stri if isSearchMatch && m.search.term != "" { content = m.highlightSearchMatches(content, changeType) } - + prefix = m.wrapPrefixForHighlight(prefix, m.resolver.LineFg(changeType), hasHighlight) return m.resolver.LineStyle(changeType, hasHighlight).Render(prefix + content) } diff --git a/app/ui/mocks/style_resolver.go b/app/ui/mocks/style_resolver.go index c6f43a83..4c303d5b 100644 --- a/app/ui/mocks/style_resolver.go +++ b/app/ui/mocks/style_resolver.go @@ -26,6 +26,9 @@ import ( // LineBgFunc: func(change diff.ChangeType) style.Color { // panic("mock out the LineBg method") // }, +// LineFgFunc: func(change diff.ChangeType) style.Color { +// panic("mock out the LineFg method") +// }, // LineStyleFunc: func(change diff.ChangeType, highlighted bool) lipgloss.Style { // panic("mock out the LineStyle method") // }, @@ -51,6 +54,9 @@ type styleResolverMock struct { // LineBgFunc mocks the LineBg method. LineBgFunc func(change diff.ChangeType) style.Color + // LineFgFunc mocks the LineFg method. + LineFgFunc func(change diff.ChangeType) style.Color + // LineStyleFunc mocks the LineStyle method. LineStyleFunc func(change diff.ChangeType, highlighted bool) lipgloss.Style @@ -77,6 +83,11 @@ type styleResolverMock struct { // Change is the change argument value. Change diff.ChangeType } + // LineFg holds details about calls to the LineFg method. + LineFg []struct { + // Change is the change argument value. + Change diff.ChangeType + } // LineStyle holds details about calls to the LineStyle method. LineStyle []struct { // Change is the change argument value. @@ -98,6 +109,7 @@ type styleResolverMock struct { lockColor sync.RWMutex lockIndicatorBg sync.RWMutex lockLineBg sync.RWMutex + lockLineFg sync.RWMutex lockLineStyle sync.RWMutex lockStyle sync.RWMutex lockWordDiffBg sync.RWMutex @@ -199,6 +211,38 @@ func (mock *styleResolverMock) LineBgCalls() []struct { return calls } +// LineFg calls LineFgFunc. +func (mock *styleResolverMock) LineFg(change diff.ChangeType) style.Color { + if mock.LineFgFunc == nil { + panic("styleResolverMock.LineFgFunc: method is nil but styleResolver.LineFg was just called") + } + callInfo := struct { + Change diff.ChangeType + }{ + Change: change, + } + mock.lockLineFg.Lock() + mock.calls.LineFg = append(mock.calls.LineFg, callInfo) + mock.lockLineFg.Unlock() + return mock.LineFgFunc(change) +} + +// LineFgCalls gets all the calls that were made to LineFg. +// Check the length with: +// +// len(mockedstyleResolver.LineFgCalls()) +func (mock *styleResolverMock) LineFgCalls() []struct { + Change diff.ChangeType +} { + var calls []struct { + Change diff.ChangeType + } + mock.lockLineFg.RLock() + calls = mock.calls.LineFg + mock.lockLineFg.RUnlock() + return calls +} + // LineStyle calls LineStyleFunc. func (mock *styleResolverMock) LineStyle(change diff.ChangeType, highlighted bool) lipgloss.Style { if mock.LineStyleFunc == nil { diff --git a/app/ui/model.go b/app/ui/model.go index e8ef5ffb..29367969 100644 --- a/app/ui/model.go +++ b/app/ui/model.go @@ -63,6 +63,7 @@ type styleResolver interface { Color(k style.ColorKey) style.Color Style(k style.StyleKey) lipgloss.Style LineBg(change diff.ChangeType) style.Color + LineFg(change diff.ChangeType) style.Color LineStyle(change diff.ChangeType, highlighted bool) lipgloss.Style WordDiffBg(change diff.ChangeType) style.Color IndicatorBg(change diff.ChangeType) style.Color diff --git a/app/ui/overlay/themeselect.go b/app/ui/overlay/themeselect.go index 5f80b566..21fb4b44 100644 --- a/app/ui/overlay/themeselect.go +++ b/app/ui/overlay/themeselect.go @@ -163,7 +163,15 @@ func (t *themeSelectOverlay) formatEntry(item ThemeItem, width int, selected boo return styled } - return " " + swatch + " " + name + // wrap the name in raw normal-fg ANSI: the swatch resets fg after rendering + // its own colored glyph, so name would otherwise inherit terminal default fg + // and render invisibly on a session whose default fg matches the theme's + // pane background (e.g. light kitty session on a light theme). + normal := string(resolver.Color(style.ColorKeyNormalFg)) + if normal == "" { + return " " + swatch + " " + name + } + return " " + swatch + " " + normal + name + string(style.ResetFg) } func (t *themeSelectOverlay) maxVisible() int { diff --git a/app/ui/style/color_key_enum.go b/app/ui/style/color_key_enum.go index 5554b65c..51154959 100644 --- a/app/ui/style/color_key_enum.go +++ b/app/ui/style/color_key_enum.go @@ -46,6 +46,8 @@ var _colorKeyParseMap = map[string]ColorKey{ "searchbg": ColorKeySearchBg, "addlinefg": ColorKeyAddLineFg, "removelinefg": ColorKeyRemoveLineFg, + "modifylinefg": ColorKeyModifyLineFg, + "searchfg": ColorKeySearchFg, "normalfg": ColorKeyNormalFg, "selectedfg": ColorKeySelectedFg, } @@ -87,8 +89,10 @@ var ( ColorKeySearchBg = ColorKey{name: "SearchBg", value: 12} ColorKeyAddLineFg = ColorKey{name: "AddLineFg", value: 13} ColorKeyRemoveLineFg = ColorKey{name: "RemoveLineFg", value: 14} - ColorKeyNormalFg = ColorKey{name: "NormalFg", value: 15} - ColorKeySelectedFg = ColorKey{name: "SelectedFg", value: 16} + ColorKeyModifyLineFg = ColorKey{name: "ModifyLineFg", value: 15} + ColorKeySearchFg = ColorKey{name: "SearchFg", value: 16} + ColorKeyNormalFg = ColorKey{name: "NormalFg", value: 17} + ColorKeySelectedFg = ColorKey{name: "SelectedFg", value: 18} ) // ColorKeyValues contains all possible enum values @@ -108,6 +112,8 @@ var ColorKeyValues = []ColorKey{ ColorKeySearchBg, ColorKeyAddLineFg, ColorKeyRemoveLineFg, + ColorKeyModifyLineFg, + ColorKeySearchFg, ColorKeyNormalFg, ColorKeySelectedFg, } @@ -129,6 +135,8 @@ var ColorKeyNames = []string{ "SearchBg", "AddLineFg", "RemoveLineFg", + "ModifyLineFg", + "SearchFg", "NormalFg", "SelectedFg", } @@ -184,6 +192,10 @@ var _ = func() bool { var _ colorKey = colorKeyAddLineFg // This avoids "defined but not used" linter error for colorKeyRemoveLineFg var _ colorKey = colorKeyRemoveLineFg + // This avoids "defined but not used" linter error for colorKeyModifyLineFg + var _ colorKey = colorKeyModifyLineFg + // This avoids "defined but not used" linter error for colorKeySearchFg + var _ colorKey = colorKeySearchFg // This avoids "defined but not used" linter error for colorKeyNormalFg var _ colorKey = colorKeyNormalFg // This avoids "defined but not used" linter error for colorKeySelectedFg diff --git a/app/ui/style/enums.go b/app/ui/style/enums.go index 79762f06..c52548c4 100644 --- a/app/ui/style/enums.go +++ b/app/ui/style/enums.go @@ -27,6 +27,8 @@ const ( colorKeySearchBg colorKeyAddLineFg colorKeyRemoveLineFg + colorKeyModifyLineFg + colorKeySearchFg colorKeyNormalFg colorKeySelectedFg ) diff --git a/app/ui/style/resolver.go b/app/ui/style/resolver.go index 3767022e..77be5b13 100644 --- a/app/ui/style/resolver.go +++ b/app/ui/style/resolver.go @@ -69,6 +69,10 @@ func (r Resolver) Color(k ColorKey) Color { return Color(ansiColor(r.colors.AddFg, 38)) case ColorKeyRemoveLineFg: return Color(ansiColor(r.colors.RemoveFg, 38)) + case ColorKeyModifyLineFg: + return Color(ansiColor(r.colors.ModifyFg, 38)) + case ColorKeySearchFg: + return Color(ansiColor(r.colors.SearchFg, 38)) case ColorKeyNormalFg: return Color(ansiColor(r.colors.Normal, 38)) case ColorKeySelectedFg: @@ -100,6 +104,23 @@ func (r Resolver) LineBg(change diff.ChangeType) Color { } } +// LineFg returns the ANSI foreground escape sequence for a diff change type. +// Used to color the +/-/~ prefix when chroma highlighting is active and the +// highlighted line style intentionally omits foreground (chroma owns content fg). +// ChangeAdd → AddFg, ChangeRemove → RemoveFg, everything else → empty. +// Modify lines (collapsed mode) are synthesized in the UI layer and not a +// diff change type — call Color(ColorKeyModifyLineFg) directly for those. +func (r Resolver) LineFg(change diff.ChangeType) Color { + switch change { + case diff.ChangeAdd: + return Color(ansiColor(r.colors.AddFg, 38)) + case diff.ChangeRemove: + return Color(ansiColor(r.colors.RemoveFg, 38)) + default: + return "" + } +} + // LineStyle returns the lipgloss.Style for a diff line based on change type // and whether syntax highlighting is active. func (r Resolver) LineStyle(change diff.ChangeType, highlighted bool) lipgloss.Style { diff --git a/app/ui/style/resolver_test.go b/app/ui/style/resolver_test.go index fe6b7cef..bfe6331d 100644 --- a/app/ui/style/resolver_test.go +++ b/app/ui/style/resolver_test.go @@ -219,6 +219,33 @@ func TestResolver_LineBg(t *testing.T) { } } +func TestResolver_LineFg(t *testing.T) { + r := NewResolver(fullColorsForTesting) + + tests := []struct { + name string + change diff.ChangeType + want bool // true if non-empty expected + }{ + {"add", diff.ChangeAdd, true}, + {"remove", diff.ChangeRemove, true}, + {"context", diff.ChangeContext, false}, + {"divider", diff.ChangeDivider, false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := r.LineFg(tt.change) + if tt.want { + assert.NotEmpty(t, string(got), "expected non-empty LineFg for %s", tt.change) + assert.Contains(t, string(got), "\033[38;2;", "expected ANSI fg sequence") + } else { + assert.Empty(t, string(got), "expected empty LineFg for %s", tt.change) + } + }) + } +} + func TestResolver_LineStyle(t *testing.T) { r := NewResolver(fullColorsForTesting) diff --git a/themes/gallery/colorblind-light b/themes/gallery/colorblind-light index 4e6280a9..2dcd3344 100644 --- a/themes/gallery/colorblind-light +++ b/themes/gallery/colorblind-light @@ -4,7 +4,7 @@ chroma-style = modus-operandi color-accent = #0031a9 -color-border = #595959 +color-border = #b0b0b0 color-normal = #000000 color-muted = #595959 color-selected-fg = #0f0f0f