detached 889f56e

fix(theme): dim indexed colors through the reported terminal palette

Dimmer.Dim resolved indexed ANSI colors (slots 0-15) via the hardcoded
classic xterm palette, while un-dimmed cells render through the
terminal's actual reported palette. Dimming 16-color content produced
dark, hyper-saturated colors unrelated to the pane's real appearance.
Route resolveForeground, resolveBackground, and Dim's underline branch
through the reported Theme.Palette first, falling back to the xterm
table for the 256-cube and unreported/disabled palettes.
commit
889f56e1ad0782df3bd8f169301e29ac4a241e5c
author
brice · · 2026-07-24 08:50 UTC
committer
brice · · 2026-07-24 10:18 UTC
parent
17afeb9
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
2 files · +130 -3
M internal/usecase/theme/theme.go +15 -3
@@ -478,7 +478,7 @@ func (d Dimmer) Dim(style renderer.Style) renderer.Style {
478478 if style.HasUnderlineColorRGB || style.HasUnderlineColor {
479479 underline := style.UnderlineColorRGB
480480 if !style.HasUnderlineColorRGB {
481- underline = xterm256Color(style.UnderlineColor)
481+ underline = indexedToRGB(d.theme, style.UnderlineColor)
482482 }
483483 out.HasUnderlineColor = false
484484 out.HasUnderlineColorRGB = true
@@ -492,7 +492,7 @@ func resolveForeground(style renderer.Style, t Theme) renderer.RGB {
492492 return style.ForegroundRGB
493493 }
494494 if style.Foreground >= 0 {
495- return xterm256Color(style.Foreground)
495+ return indexedToRGB(t, style.Foreground)
496496 }
497497 return t.Foreground
498498 }
@@ -502,11 +502,23 @@ func resolveBackground(style renderer.Style, t Theme) renderer.RGB {
502502 return style.BackgroundRGB
503503 }
504504 if style.Background >= 0 {
505- return xterm256Color(style.Background)
505+ return indexedToRGB(t, style.Background)
506506 }
507507 return t.Background
508508 }
509509
510+// indexedToRGB resolves an ANSI color index through the terminal's reported
511+// palette when available, so dimming blends toward what the client actually
512+// renders rather than the hardcoded classic xterm table. It falls back to
513+// the xterm table for the 256-cube, for unreported/unknown slots, and when
514+// palette inheritance is disabled.
515+func indexedToRGB(t Theme, index int) renderer.RGB {
516+ if rgb, ok := t.PaletteColor(index); ok {
517+ return rgb
518+ }
519+ return xterm256Color(index)
520+}
521+
510522 func blendPercent(a, b renderer.RGB, percent int) renderer.RGB {
511523 return renderer.RGB{
512524 R: blendBytePercent(a.R, b.R, percent),
@@ -478,7 +478,7 @@ func (d Dimmer) Dim(style renderer.Style) renderer.Style {
478 if style.HasUnderlineColorRGB || style.HasUnderlineColor {478 if style.HasUnderlineColorRGB || style.HasUnderlineColor {
479 underline := style.UnderlineColorRGB479 underline := style.UnderlineColorRGB
480 if !style.HasUnderlineColorRGB {480 if !style.HasUnderlineColorRGB {
481- underline = xterm256Color(style.UnderlineColor)481+ underline = indexedToRGB(d.theme, style.UnderlineColor)
482 }482 }
483 out.HasUnderlineColor = false483 out.HasUnderlineColor = false
484 out.HasUnderlineColorRGB = true484 out.HasUnderlineColorRGB = true
@@ -492,7 +492,7 @@ func resolveForeground(style renderer.Style, t Theme) renderer.RGB {
492 return style.ForegroundRGB492 return style.ForegroundRGB
493 }493 }
494 if style.Foreground >= 0 {494 if style.Foreground >= 0 {
495- return xterm256Color(style.Foreground)495+ return indexedToRGB(t, style.Foreground)
496 }496 }
497 return t.Foreground497 return t.Foreground
498 }498 }
@@ -502,11 +502,23 @@ func resolveBackground(style renderer.Style, t Theme) renderer.RGB {
502 return style.BackgroundRGB502 return style.BackgroundRGB
503 }503 }
504 if style.Background >= 0 {504 if style.Background >= 0 {
505- return xterm256Color(style.Background)505+ return indexedToRGB(t, style.Background)
506 }506 }
507 return t.Background507 return t.Background
508 }508 }
509 509
510+// indexedToRGB resolves an ANSI color index through the terminal's reported
511+// palette when available, so dimming blends toward what the client actually
512+// renders rather than the hardcoded classic xterm table. It falls back to
513+// the xterm table for the 256-cube, for unreported/unknown slots, and when
514+// palette inheritance is disabled.
515+func indexedToRGB(t Theme, index int) renderer.RGB {
516+ if rgb, ok := t.PaletteColor(index); ok {
517+ return rgb
518+ }
519+ return xterm256Color(index)
520+}
521+
510 func blendPercent(a, b renderer.RGB, percent int) renderer.RGB {522 func blendPercent(a, b renderer.RGB, percent int) renderer.RGB {
511 return renderer.RGB{523 return renderer.RGB{
512 R: blendBytePercent(a.R, b.R, percent),524 R: blendBytePercent(a.R, b.R, percent),
M internal/usecase/theme/theme_test.go +115 -0
@@ -429,6 +429,121 @@ func TestDimmerDefaults(t *testing.T) {
429429 }
430430 }
431431
432+// TestDimmerUsesReportedPalette verifies that Dim resolves indexed ANSI
433+// colors (slots 0-15) through the terminal's reported palette when known,
434+// so dimming a pane blends toward what the client actually rendered rather
435+// than the hardcoded classic xterm table. It falls back to the xterm table
436+// when the palette is unreported, disabled, or the slot is outside 0-15.
437+func TestDimmerUsesReportedPalette(t *testing.T) {
438+ background := renderer.RGB{R: 10, G: 20, B: 30}
439+ foreground := renderer.RGB{R: 200, G: 200, B: 200}
440+ baseTheme := Theme{
441+ Foreground: foreground, Background: background,
442+ HasFG: true, HasBG: true, Known: true, TrueColor: true,
443+ }
444+
445+ reportedSlot2 := renderer.RGB{R: 60, G: 180, B: 75}
446+ reportedSlot4 := renderer.RGB{R: 30, G: 100, B: 200}
447+ reportedSlot6 := renderer.RGB{R: 50, G: 150, B: 150}
448+
449+ withPalette := baseTheme
450+ withPalette.UsePalette = true
451+ withPalette.Palette[2] = reportedSlot2
452+ withPalette.Palette[4] = reportedSlot4
453+ withPalette.Palette[6] = reportedSlot6
454+ withPalette.PaletteKnown = 1<<2 | 1<<4 | 1<<6
455+
456+ unknownBits := baseTheme
457+ unknownBits.UsePalette = true
458+ unknownBits.Palette[2] = reportedSlot2
459+ unknownBits.Palette[4] = reportedSlot4
460+ unknownBits.Palette[6] = reportedSlot6
461+ // PaletteKnown left zero: reported RGBs exist but no bit says they're valid.
462+
463+ paletteDisabled := baseTheme
464+ paletteDisabled.UsePalette = false
465+ paletteDisabled.Palette[2] = reportedSlot2
466+ paletteDisabled.PaletteKnown = 1 << 2
467+
468+ dimmer := NewDimmer(baseTheme)
469+ dimBG := func(raw renderer.RGB) renderer.RGB { return blendPercent(raw, background, dimmer.backgroundPercent) }
470+ dimFG := func(raw, bg renderer.RGB) renderer.RGB { return blendPercent(raw, bg, dimmer.foregroundPercent) }
471+
472+ t.Run("indexed foreground blends from the reported palette color when known", func(t *testing.T) {
473+ style := renderer.Style{Foreground: 2, Background: -1}
474+ got := NewDimmer(withPalette).Dim(style)
475+
476+ dimmedBG := dimBG(background)
477+ require.Equal(t, dimmedBG, got.BackgroundRGB)
478+ require.Equal(t, dimFG(reportedSlot2, dimmedBG), got.ForegroundRGB)
479+ require.NotEqual(t, dimFG(xterm256Color(2), dimmedBG), got.ForegroundRGB, "must not use the xterm table when the palette is known")
480+ })
481+
482+ t.Run("indexed foreground falls back to xterm when the palette bit is unknown", func(t *testing.T) {
483+ style := renderer.Style{Foreground: 2, Background: -1}
484+ got := NewDimmer(unknownBits).Dim(style)
485+
486+ dimmedBG := dimBG(background)
487+ require.Equal(t, dimFG(xterm256Color(2), dimmedBG), got.ForegroundRGB)
488+ })
489+
490+ t.Run("indexed foreground falls back to xterm when UsePalette is disabled", func(t *testing.T) {
491+ style := renderer.Style{Foreground: 2, Background: -1}
492+ got := NewDimmer(paletteDisabled).Dim(style)
493+
494+ dimmedBG := dimBG(background)
495+ require.Equal(t, dimFG(xterm256Color(2), dimmedBG), got.ForegroundRGB)
496+ })
497+
498+ t.Run("indexed background blends from the reported palette color when known", func(t *testing.T) {
499+ style := renderer.Style{Foreground: -1, Background: 4}
500+ got := NewDimmer(withPalette).Dim(style)
501+
502+ dimmedBG := dimBG(reportedSlot4)
503+ require.Equal(t, dimmedBG, got.BackgroundRGB)
504+ require.Equal(t, dimFG(foreground, dimmedBG), got.ForegroundRGB)
505+ })
506+
507+ t.Run("indexed background falls back to xterm when the palette bit is unknown", func(t *testing.T) {
508+ style := renderer.Style{Foreground: -1, Background: 4}
509+ got := NewDimmer(unknownBits).Dim(style)
510+
511+ dimmedBG := dimBG(xterm256Color(4))
512+ require.Equal(t, dimmedBG, got.BackgroundRGB)
513+ })
514+
515+ t.Run("indexed underline blends from the reported palette color when known", func(t *testing.T) {
516+ style := renderer.Style{
517+ Foreground: -1, Background: -1,
518+ HasUnderlineColor: true, UnderlineColor: 6,
519+ }
520+ got := NewDimmer(withPalette).Dim(style)
521+
522+ dimmedBG := dimBG(background)
523+ require.True(t, got.HasUnderlineColorRGB)
524+ require.Equal(t, dimFG(reportedSlot6, dimmedBG), got.UnderlineColorRGB)
525+ })
526+
527+ t.Run("indexed underline falls back to xterm when the palette bit is unknown", func(t *testing.T) {
528+ style := renderer.Style{
529+ Foreground: -1, Background: -1,
530+ HasUnderlineColor: true, UnderlineColor: 6,
531+ }
532+ got := NewDimmer(unknownBits).Dim(style)
533+
534+ dimmedBG := dimBG(background)
535+ require.Equal(t, dimFG(xterm256Color(6), dimmedBG), got.UnderlineColorRGB)
536+ })
537+
538+ t.Run("256-cube index is untouched by a reported palette", func(t *testing.T) {
539+ style := renderer.Style{Foreground: 196, Background: -1}
540+ got := NewDimmer(withPalette).Dim(style)
541+
542+ dimmedBG := dimBG(background)
543+ require.Equal(t, dimFG(xterm256Color(196), dimmedBG), got.ForegroundRGB)
544+ })
545+}
546+
432547 func TestStyleHelpersFallbackAndThemed(t *testing.T) {
433548 unknown := Theme{}
434549 if got := StatusBarStyle(unknown); !got.Equal(renderer.DefaultStyle()) {
@@ -429,6 +429,121 @@ func TestDimmerDefaults(t *testing.T) {
429 }429 }
430 }430 }
431 431
432+// TestDimmerUsesReportedPalette verifies that Dim resolves indexed ANSI
433+// colors (slots 0-15) through the terminal's reported palette when known,
434+// so dimming a pane blends toward what the client actually rendered rather
435+// than the hardcoded classic xterm table. It falls back to the xterm table
436+// when the palette is unreported, disabled, or the slot is outside 0-15.
437+func TestDimmerUsesReportedPalette(t *testing.T) {
438+ background := renderer.RGB{R: 10, G: 20, B: 30}
439+ foreground := renderer.RGB{R: 200, G: 200, B: 200}
440+ baseTheme := Theme{
441+ Foreground: foreground, Background: background,
442+ HasFG: true, HasBG: true, Known: true, TrueColor: true,
443+ }
444+
445+ reportedSlot2 := renderer.RGB{R: 60, G: 180, B: 75}
446+ reportedSlot4 := renderer.RGB{R: 30, G: 100, B: 200}
447+ reportedSlot6 := renderer.RGB{R: 50, G: 150, B: 150}
448+
449+ withPalette := baseTheme
450+ withPalette.UsePalette = true
451+ withPalette.Palette[2] = reportedSlot2
452+ withPalette.Palette[4] = reportedSlot4
453+ withPalette.Palette[6] = reportedSlot6
454+ withPalette.PaletteKnown = 1<<2 | 1<<4 | 1<<6
455+
456+ unknownBits := baseTheme
457+ unknownBits.UsePalette = true
458+ unknownBits.Palette[2] = reportedSlot2
459+ unknownBits.Palette[4] = reportedSlot4
460+ unknownBits.Palette[6] = reportedSlot6
461+ // PaletteKnown left zero: reported RGBs exist but no bit says they're valid.
462+
463+ paletteDisabled := baseTheme
464+ paletteDisabled.UsePalette = false
465+ paletteDisabled.Palette[2] = reportedSlot2
466+ paletteDisabled.PaletteKnown = 1 << 2
467+
468+ dimmer := NewDimmer(baseTheme)
469+ dimBG := func(raw renderer.RGB) renderer.RGB { return blendPercent(raw, background, dimmer.backgroundPercent) }
470+ dimFG := func(raw, bg renderer.RGB) renderer.RGB { return blendPercent(raw, bg, dimmer.foregroundPercent) }
471+
472+ t.Run("indexed foreground blends from the reported palette color when known", func(t *testing.T) {
473+ style := renderer.Style{Foreground: 2, Background: -1}
474+ got := NewDimmer(withPalette).Dim(style)
475+
476+ dimmedBG := dimBG(background)
477+ require.Equal(t, dimmedBG, got.BackgroundRGB)
478+ require.Equal(t, dimFG(reportedSlot2, dimmedBG), got.ForegroundRGB)
479+ require.NotEqual(t, dimFG(xterm256Color(2), dimmedBG), got.ForegroundRGB, "must not use the xterm table when the palette is known")
480+ })
481+
482+ t.Run("indexed foreground falls back to xterm when the palette bit is unknown", func(t *testing.T) {
483+ style := renderer.Style{Foreground: 2, Background: -1}
484+ got := NewDimmer(unknownBits).Dim(style)
485+
486+ dimmedBG := dimBG(background)
487+ require.Equal(t, dimFG(xterm256Color(2), dimmedBG), got.ForegroundRGB)
488+ })
489+
490+ t.Run("indexed foreground falls back to xterm when UsePalette is disabled", func(t *testing.T) {
491+ style := renderer.Style{Foreground: 2, Background: -1}
492+ got := NewDimmer(paletteDisabled).Dim(style)
493+
494+ dimmedBG := dimBG(background)
495+ require.Equal(t, dimFG(xterm256Color(2), dimmedBG), got.ForegroundRGB)
496+ })
497+
498+ t.Run("indexed background blends from the reported palette color when known", func(t *testing.T) {
499+ style := renderer.Style{Foreground: -1, Background: 4}
500+ got := NewDimmer(withPalette).Dim(style)
501+
502+ dimmedBG := dimBG(reportedSlot4)
503+ require.Equal(t, dimmedBG, got.BackgroundRGB)
504+ require.Equal(t, dimFG(foreground, dimmedBG), got.ForegroundRGB)
505+ })
506+
507+ t.Run("indexed background falls back to xterm when the palette bit is unknown", func(t *testing.T) {
508+ style := renderer.Style{Foreground: -1, Background: 4}
509+ got := NewDimmer(unknownBits).Dim(style)
510+
511+ dimmedBG := dimBG(xterm256Color(4))
512+ require.Equal(t, dimmedBG, got.BackgroundRGB)
513+ })
514+
515+ t.Run("indexed underline blends from the reported palette color when known", func(t *testing.T) {
516+ style := renderer.Style{
517+ Foreground: -1, Background: -1,
518+ HasUnderlineColor: true, UnderlineColor: 6,
519+ }
520+ got := NewDimmer(withPalette).Dim(style)
521+
522+ dimmedBG := dimBG(background)
523+ require.True(t, got.HasUnderlineColorRGB)
524+ require.Equal(t, dimFG(reportedSlot6, dimmedBG), got.UnderlineColorRGB)
525+ })
526+
527+ t.Run("indexed underline falls back to xterm when the palette bit is unknown", func(t *testing.T) {
528+ style := renderer.Style{
529+ Foreground: -1, Background: -1,
530+ HasUnderlineColor: true, UnderlineColor: 6,
531+ }
532+ got := NewDimmer(unknownBits).Dim(style)
533+
534+ dimmedBG := dimBG(background)
535+ require.Equal(t, dimFG(xterm256Color(6), dimmedBG), got.UnderlineColorRGB)
536+ })
537+
538+ t.Run("256-cube index is untouched by a reported palette", func(t *testing.T) {
539+ style := renderer.Style{Foreground: 196, Background: -1}
540+ got := NewDimmer(withPalette).Dim(style)
541+
542+ dimmedBG := dimBG(background)
543+ require.Equal(t, dimFG(xterm256Color(196), dimmedBG), got.ForegroundRGB)
544+ })
545+}
546+
432 func TestStyleHelpersFallbackAndThemed(t *testing.T) {547 func TestStyleHelpersFallbackAndThemed(t *testing.T) {
433 unknown := Theme{}548 unknown := Theme{}
434 if got := StatusBarStyle(unknown); !got.Equal(renderer.DefaultStyle()) {549 if got := StatusBarStyle(unknown); !got.Equal(renderer.DefaultStyle()) {

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 12

Tags 11

↑↓ moveenter openesc close