detached ce901de

feat(theme): derive chrome accents from terminal palette

commit
ce901de93d0052421055060ccbea710896ea8f60
author
brice · · 2026-07-17 08:58 UTC
parent
efa8333
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
3 files · +342 -16
M internal/usecase/theme/styles.go +3 -3
@@ -92,12 +92,12 @@ func NewStyles(t Theme) Styles {
9292
9393 TabName: EmphasisStyle(statusBar, t),
9494 TabNameActive: EmphasisStyle(accent, t),
95- TabTitle: MutedVariantStyle(statusBar, t),
96- TabTitleActive: MutedVariantStyle(accent, t),
95+ TabTitle: PaletteMutedVariantStyle(statusBar, t),
96+ TabTitleActive: PaletteMutedVariantStyle(accent, t),
9797
9898 PickerName: EmphasisStyle(renderer.DefaultStyle(), t),
9999 PickerSelectionName: EmphasisStyle(selection, t),
100- PickerSelectionMuted: MutedVariantStyle(selection, t),
100+ PickerSelectionMuted: PaletteMutedVariantStyle(selection, t),
101101 PickerSeparator: MutedTextStyle(t),
102102 }
103103 }
@@ -92,12 +92,12 @@ func NewStyles(t Theme) Styles {
92 92
93 TabName: EmphasisStyle(statusBar, t),93 TabName: EmphasisStyle(statusBar, t),
94 TabNameActive: EmphasisStyle(accent, t),94 TabNameActive: EmphasisStyle(accent, t),
95- TabTitle: MutedVariantStyle(statusBar, t),95+ TabTitle: PaletteMutedVariantStyle(statusBar, t),
96- TabTitleActive: MutedVariantStyle(accent, t),96+ TabTitleActive: PaletteMutedVariantStyle(accent, t),
97 97
98 PickerName: EmphasisStyle(renderer.DefaultStyle(), t),98 PickerName: EmphasisStyle(renderer.DefaultStyle(), t),
99 PickerSelectionName: EmphasisStyle(selection, t),99 PickerSelectionName: EmphasisStyle(selection, t),
100- PickerSelectionMuted: MutedVariantStyle(selection, t),100+ PickerSelectionMuted: PaletteMutedVariantStyle(selection, t),
101 PickerSeparator: MutedTextStyle(t),101 PickerSeparator: MutedTextStyle(t),
102 }102 }
103 }103 }
M internal/usecase/theme/styles_test.go +224 -0
@@ -241,6 +241,230 @@ func TestThemeIsComparable(t *testing.T) {
241241 require.True(t, themes[BuiltinDark])
242242 }
243243
244+func TestPaletteAccentBackground(t *testing.T) {
245+ base := Theme{
246+ Foreground: renderer.RGB{R: 200, G: 200, B: 200},
247+ Background: renderer.RGB{R: 100, G: 100, B: 100},
248+ HasFG: true,
249+ HasBG: true,
250+ Known: true,
251+ TrueColor: true,
252+ UsePalette: true,
253+ }
254+ neutral := Blend(base.Background, base.Foreground, accentBlend)
255+
256+ tests := []struct {
257+ name string
258+ palette [16]renderer.RGB
259+ known uint16
260+ usePalette bool
261+ want renderer.RGB
262+ wantContrastMin bool
263+ }{
264+ {
265+ name: "slot 4 supplies a contrasting accent background",
266+ palette: [16]renderer.RGB{4: {B: 255}, 12: {R: 200, G: 200, B: 200}},
267+ known: 1<<4 | 1<<12,
268+ usePalette: true,
269+ want: Blend(base.Background, renderer.RGB{B: 255}, accentBlend),
270+ wantContrastMin: true,
271+ },
272+ {
273+ name: "slot 4 failure escalates to slot 12",
274+ palette: [16]renderer.RGB{4: {R: 200, G: 200, B: 200}, 12: {}},
275+ known: 1<<4 | 1<<12,
276+ usePalette: true,
277+ want: Blend(base.Background, renderer.RGB{}, accentBlend),
278+ wantContrastMin: true,
279+ },
280+ {
281+ name: "both palette blues failing uses exact neutral blend",
282+ palette: [16]renderer.RGB{4: {R: 200, G: 200, B: 200}, 12: {R: 200, G: 200, B: 200}},
283+ known: 1<<4 | 1<<12,
284+ usePalette: true,
285+ want: neutral,
286+ },
287+ {
288+ name: "missing palette blues uses exact neutral blend",
289+ usePalette: true,
290+ want: neutral,
291+ },
292+ {
293+ name: "palette disabled preserves neutral accent",
294+ palette: [16]renderer.RGB{4: {B: 255}},
295+ known: 1 << 4,
296+ usePalette: false,
297+ want: neutral,
298+ },
299+ }
300+
301+ for _, tt := range tests {
302+ t.Run(tt.name, func(t *testing.T) {
303+ theme := base
304+ theme.Palette = tt.palette
305+ theme.PaletteKnown = tt.known
306+ theme.UsePalette = tt.usePalette
307+
308+ got := AccentStyle(theme)
309+ require.True(t, got.HasBackgroundRGB)
310+ require.Equal(t, tt.want, got.BackgroundRGB)
311+ if tt.wantContrastMin {
312+ require.GreaterOrEqual(t, ContrastRatio(theme.Foreground, got.BackgroundRGB), accentContrastMin)
313+ }
314+ })
315+ }
316+}
317+
318+func TestPaletteTitleHueDerivation(t *testing.T) {
319+ base := Theme{
320+ Foreground: renderer.RGB{R: 200, G: 200, B: 200},
321+ Background: renderer.RGB{R: 100, G: 100, B: 100},
322+ HasFG: true,
323+ HasBG: true,
324+ Known: true,
325+ TrueColor: true,
326+ UsePalette: true,
327+ }
328+
329+ tests := []struct {
330+ name string
331+ theme Theme
332+ palette [16]renderer.RGB
333+ known uint16
334+ }{
335+ {
336+ name: "slot 4 is used when it contrasts for each title",
337+ theme: Theme{
338+ Foreground: renderer.RGB{R: 240, G: 240, B: 240},
339+ Background: renderer.RGB{R: 50, G: 50, B: 50},
340+ HasFG: true, HasBG: true, Known: true, TrueColor: true, UsePalette: true,
341+ },
342+ palette: [16]renderer.RGB{4: {}, 12: {R: 200, G: 200, B: 200}},
343+
344+ known: 1<<4 | 1<<12,
345+ },
346+ {
347+ name: "each title escalates independently from slot 4 to slot 12",
348+ palette: [16]renderer.RGB{4: {}, 12: {R: 255, G: 255, B: 255}},
349+ known: 1<<4 | 1<<12,
350+ },
351+ {
352+ name: "both title hues failing retains neutral muted styles",
353+ palette: [16]renderer.RGB{4: {R: 200, G: 200, B: 200}, 12: {R: 200, G: 200, B: 200}},
354+ known: 1<<4 | 1<<12,
355+ },
356+ }
357+
358+ for _, tt := range tests {
359+ t.Run(tt.name, func(t *testing.T) {
360+ theme := base
361+ if tt.theme.Known {
362+ theme = tt.theme
363+ }
364+ theme.Palette = tt.palette
365+ theme.PaletteKnown = tt.known
366+ styles := NewStyles(theme)
367+ status := StatusBarStyle(theme)
368+ accent := AccentStyle(theme)
369+
370+ if tt.name == "both title hues failing retains neutral muted styles" {
371+ require.True(t, styles.TabTitle.Equal(MutedVariantStyle(status, theme)))
372+ require.True(t, styles.TabTitleActive.Equal(MutedVariantStyle(accent, theme)))
373+ return
374+ }
375+
376+ require.True(t, styles.TabTitle.HasForegroundRGB)
377+ require.True(t, styles.TabTitleActive.HasForegroundRGB)
378+ require.GreaterOrEqual(t, ContrastRatio(styles.TabTitle.ForegroundRGB, status.BackgroundRGB), accentContrastMin)
379+ require.GreaterOrEqual(t, ContrastRatio(styles.TabTitleActive.ForegroundRGB, accent.BackgroundRGB), accentContrastMin)
380+
381+ if tt.name == "slot 4 is used when it contrasts for each title" {
382+ require.Equal(t, Blend(theme.Foreground, theme.Palette[4], titleHueBlend), styles.TabTitle.ForegroundRGB)
383+ require.Equal(t, Blend(theme.Foreground, theme.Palette[4], titleHueBlend), styles.TabTitleActive.ForegroundRGB)
384+ }
385+ if tt.name == "each title escalates independently from slot 4 to slot 12" {
386+ require.Equal(t, Blend(theme.Foreground, theme.Palette[12], titleHueBlend), styles.TabTitle.ForegroundRGB)
387+ require.Equal(t, Blend(theme.Foreground, theme.Palette[4], titleHueBlend), styles.TabTitleActive.ForegroundRGB)
388+ }
389+ })
390+ }
391+}
392+
393+func TestPaletteIndexedForegroundFallback(t *testing.T) {
394+ base := Theme{UsePalette: true, SchemeKnown: true, Known: true}
395+
396+ tests := []struct {
397+ name string
398+ theme Theme
399+ want int
400+ ok bool
401+ }{
402+ {name: "dark scheme uses bright blue", theme: base, want: 12, ok: true},
403+ {name: "light scheme uses blue", theme: func() Theme { t := base; t.Light = true; return t }(), want: 4, ok: true},
404+ {name: "unknown scheme remains neutral", theme: Theme{UsePalette: true, Known: true}},
405+ {name: "known RGB blue that cannot be evaluated remains neutral", theme: Theme{UsePalette: true, SchemeKnown: true, PaletteKnown: 1 << 4}},
406+ }
407+
408+ for _, tt := range tests {
409+ t.Run(tt.name, func(t *testing.T) {
410+ styles := NewStyles(tt.theme)
411+ baselineTheme := tt.theme
412+ baselineTheme.UsePalette = false
413+ baseline := NewStyles(baselineTheme)
414+ for name, style := range map[string]renderer.Style{
415+ "title": styles.TabTitle,
416+ "active title": styles.TabTitleActive,
417+ "border": styles.Border,
418+ "muted text": styles.PaletteDesc,
419+ } {
420+ require.False(t, style.HasBackgroundRGB, "%s must never receive an indexed background", name)
421+ if tt.ok {
422+ require.False(t, style.HasForegroundRGB, "%s must use an indexed foreground", name)
423+ require.Equal(t, tt.want, style.Foreground, name)
424+ } else {
425+ want := map[string]renderer.Style{
426+ "title": baseline.TabTitle, "active title": baseline.TabTitleActive,
427+ "border": baseline.Border, "muted text": baseline.PaletteDesc,
428+ }[name]
429+ require.Equal(t, want, style, name)
430+ }
431+ }
432+ })
433+ }
434+}
435+
436+func TestPaletteMissingRGBUsesOnlyIndexedTitleForeground(t *testing.T) {
437+ theme := Theme{
438+ Foreground: renderer.RGB{R: 240, G: 240, B: 240},
439+ Background: renderer.RGB{R: 30, G: 30, B: 30},
440+ HasFG: true, HasBG: true, Known: true, TrueColor: true,
441+ UsePalette: true, SchemeKnown: true,
442+ }
443+ styles := NewStyles(theme)
444+ for name, style := range map[string]renderer.Style{
445+ "title": styles.TabTitle, "active title": styles.TabTitleActive,
446+ "border": styles.Border, "muted text": styles.PaletteDesc,
447+ } {
448+ require.False(t, style.HasForegroundRGB, "%s must use an indexed foreground", name)
449+ require.Equal(t, ansiBrightBlue, style.Foreground, name)
450+ require.Equal(t, -1, style.Background, "%s must not use an indexed background", name)
451+ }
452+}
453+
454+func TestPaletteDisabledAndForcedModesPreserveExistingStyles(t *testing.T) {
455+ palette := [16]renderer.RGB{4: {B: 255}, 12: {R: 255, G: 255, B: 255}}
456+ usableTheme := Theme{Foreground: renderer.RGB{R: 200, G: 200, B: 200}, Background: renderer.RGB{R: 100, G: 100, B: 100}, HasFG: true, HasBG: true, Known: true, TrueColor: true}
457+ withDisabledPalette := usableTheme
458+ withDisabledPalette.Palette = palette
459+ withDisabledPalette.PaletteKnown = 1<<4 | 1<<12
460+ withDisabledPalette.UsePalette = false
461+
462+ require.Equal(t, NewStyles(usableTheme), NewStyles(withDisabledPalette))
463+
464+ forced := Theme{Known: true, SchemeKnown: true, UsePalette: false}
465+ require.Equal(t, NewStyles(Theme{Known: true}), NewStyles(forced))
466+}
467+
244468 func TestNewStylesComposesSemanticStyles(t *testing.T) {
245469 theme := Theme{
246470 Foreground: renderer.RGB{R: 0xc8, G: 0xc8, B: 0xc8},
@@ -241,6 +241,230 @@ func TestThemeIsComparable(t *testing.T) {
241 require.True(t, themes[BuiltinDark])241 require.True(t, themes[BuiltinDark])
242 }242 }
243 243
244+func TestPaletteAccentBackground(t *testing.T) {
245+ base := Theme{
246+ Foreground: renderer.RGB{R: 200, G: 200, B: 200},
247+ Background: renderer.RGB{R: 100, G: 100, B: 100},
248+ HasFG: true,
249+ HasBG: true,
250+ Known: true,
251+ TrueColor: true,
252+ UsePalette: true,
253+ }
254+ neutral := Blend(base.Background, base.Foreground, accentBlend)
255+
256+ tests := []struct {
257+ name string
258+ palette [16]renderer.RGB
259+ known uint16
260+ usePalette bool
261+ want renderer.RGB
262+ wantContrastMin bool
263+ }{
264+ {
265+ name: "slot 4 supplies a contrasting accent background",
266+ palette: [16]renderer.RGB{4: {B: 255}, 12: {R: 200, G: 200, B: 200}},
267+ known: 1<<4 | 1<<12,
268+ usePalette: true,
269+ want: Blend(base.Background, renderer.RGB{B: 255}, accentBlend),
270+ wantContrastMin: true,
271+ },
272+ {
273+ name: "slot 4 failure escalates to slot 12",
274+ palette: [16]renderer.RGB{4: {R: 200, G: 200, B: 200}, 12: {}},
275+ known: 1<<4 | 1<<12,
276+ usePalette: true,
277+ want: Blend(base.Background, renderer.RGB{}, accentBlend),
278+ wantContrastMin: true,
279+ },
280+ {
281+ name: "both palette blues failing uses exact neutral blend",
282+ palette: [16]renderer.RGB{4: {R: 200, G: 200, B: 200}, 12: {R: 200, G: 200, B: 200}},
283+ known: 1<<4 | 1<<12,
284+ usePalette: true,
285+ want: neutral,
286+ },
287+ {
288+ name: "missing palette blues uses exact neutral blend",
289+ usePalette: true,
290+ want: neutral,
291+ },
292+ {
293+ name: "palette disabled preserves neutral accent",
294+ palette: [16]renderer.RGB{4: {B: 255}},
295+ known: 1 << 4,
296+ usePalette: false,
297+ want: neutral,
298+ },
299+ }
300+
301+ for _, tt := range tests {
302+ t.Run(tt.name, func(t *testing.T) {
303+ theme := base
304+ theme.Palette = tt.palette
305+ theme.PaletteKnown = tt.known
306+ theme.UsePalette = tt.usePalette
307+
308+ got := AccentStyle(theme)
309+ require.True(t, got.HasBackgroundRGB)
310+ require.Equal(t, tt.want, got.BackgroundRGB)
311+ if tt.wantContrastMin {
312+ require.GreaterOrEqual(t, ContrastRatio(theme.Foreground, got.BackgroundRGB), accentContrastMin)
313+ }
314+ })
315+ }
316+}
317+
318+func TestPaletteTitleHueDerivation(t *testing.T) {
319+ base := Theme{
320+ Foreground: renderer.RGB{R: 200, G: 200, B: 200},
321+ Background: renderer.RGB{R: 100, G: 100, B: 100},
322+ HasFG: true,
323+ HasBG: true,
324+ Known: true,
325+ TrueColor: true,
326+ UsePalette: true,
327+ }
328+
329+ tests := []struct {
330+ name string
331+ theme Theme
332+ palette [16]renderer.RGB
333+ known uint16
334+ }{
335+ {
336+ name: "slot 4 is used when it contrasts for each title",
337+ theme: Theme{
338+ Foreground: renderer.RGB{R: 240, G: 240, B: 240},
339+ Background: renderer.RGB{R: 50, G: 50, B: 50},
340+ HasFG: true, HasBG: true, Known: true, TrueColor: true, UsePalette: true,
341+ },
342+ palette: [16]renderer.RGB{4: {}, 12: {R: 200, G: 200, B: 200}},
343+
344+ known: 1<<4 | 1<<12,
345+ },
346+ {
347+ name: "each title escalates independently from slot 4 to slot 12",
348+ palette: [16]renderer.RGB{4: {}, 12: {R: 255, G: 255, B: 255}},
349+ known: 1<<4 | 1<<12,
350+ },
351+ {
352+ name: "both title hues failing retains neutral muted styles",
353+ palette: [16]renderer.RGB{4: {R: 200, G: 200, B: 200}, 12: {R: 200, G: 200, B: 200}},
354+ known: 1<<4 | 1<<12,
355+ },
356+ }
357+
358+ for _, tt := range tests {
359+ t.Run(tt.name, func(t *testing.T) {
360+ theme := base
361+ if tt.theme.Known {
362+ theme = tt.theme
363+ }
364+ theme.Palette = tt.palette
365+ theme.PaletteKnown = tt.known
366+ styles := NewStyles(theme)
367+ status := StatusBarStyle(theme)
368+ accent := AccentStyle(theme)
369+
370+ if tt.name == "both title hues failing retains neutral muted styles" {
371+ require.True(t, styles.TabTitle.Equal(MutedVariantStyle(status, theme)))
372+ require.True(t, styles.TabTitleActive.Equal(MutedVariantStyle(accent, theme)))
373+ return
374+ }
375+
376+ require.True(t, styles.TabTitle.HasForegroundRGB)
377+ require.True(t, styles.TabTitleActive.HasForegroundRGB)
378+ require.GreaterOrEqual(t, ContrastRatio(styles.TabTitle.ForegroundRGB, status.BackgroundRGB), accentContrastMin)
379+ require.GreaterOrEqual(t, ContrastRatio(styles.TabTitleActive.ForegroundRGB, accent.BackgroundRGB), accentContrastMin)
380+
381+ if tt.name == "slot 4 is used when it contrasts for each title" {
382+ require.Equal(t, Blend(theme.Foreground, theme.Palette[4], titleHueBlend), styles.TabTitle.ForegroundRGB)
383+ require.Equal(t, Blend(theme.Foreground, theme.Palette[4], titleHueBlend), styles.TabTitleActive.ForegroundRGB)
384+ }
385+ if tt.name == "each title escalates independently from slot 4 to slot 12" {
386+ require.Equal(t, Blend(theme.Foreground, theme.Palette[12], titleHueBlend), styles.TabTitle.ForegroundRGB)
387+ require.Equal(t, Blend(theme.Foreground, theme.Palette[4], titleHueBlend), styles.TabTitleActive.ForegroundRGB)
388+ }
389+ })
390+ }
391+}
392+
393+func TestPaletteIndexedForegroundFallback(t *testing.T) {
394+ base := Theme{UsePalette: true, SchemeKnown: true, Known: true}
395+
396+ tests := []struct {
397+ name string
398+ theme Theme
399+ want int
400+ ok bool
401+ }{
402+ {name: "dark scheme uses bright blue", theme: base, want: 12, ok: true},
403+ {name: "light scheme uses blue", theme: func() Theme { t := base; t.Light = true; return t }(), want: 4, ok: true},
404+ {name: "unknown scheme remains neutral", theme: Theme{UsePalette: true, Known: true}},
405+ {name: "known RGB blue that cannot be evaluated remains neutral", theme: Theme{UsePalette: true, SchemeKnown: true, PaletteKnown: 1 << 4}},
406+ }
407+
408+ for _, tt := range tests {
409+ t.Run(tt.name, func(t *testing.T) {
410+ styles := NewStyles(tt.theme)
411+ baselineTheme := tt.theme
412+ baselineTheme.UsePalette = false
413+ baseline := NewStyles(baselineTheme)
414+ for name, style := range map[string]renderer.Style{
415+ "title": styles.TabTitle,
416+ "active title": styles.TabTitleActive,
417+ "border": styles.Border,
418+ "muted text": styles.PaletteDesc,
419+ } {
420+ require.False(t, style.HasBackgroundRGB, "%s must never receive an indexed background", name)
421+ if tt.ok {
422+ require.False(t, style.HasForegroundRGB, "%s must use an indexed foreground", name)
423+ require.Equal(t, tt.want, style.Foreground, name)
424+ } else {
425+ want := map[string]renderer.Style{
426+ "title": baseline.TabTitle, "active title": baseline.TabTitleActive,
427+ "border": baseline.Border, "muted text": baseline.PaletteDesc,
428+ }[name]
429+ require.Equal(t, want, style, name)
430+ }
431+ }
432+ })
433+ }
434+}
435+
436+func TestPaletteMissingRGBUsesOnlyIndexedTitleForeground(t *testing.T) {
437+ theme := Theme{
438+ Foreground: renderer.RGB{R: 240, G: 240, B: 240},
439+ Background: renderer.RGB{R: 30, G: 30, B: 30},
440+ HasFG: true, HasBG: true, Known: true, TrueColor: true,
441+ UsePalette: true, SchemeKnown: true,
442+ }
443+ styles := NewStyles(theme)
444+ for name, style := range map[string]renderer.Style{
445+ "title": styles.TabTitle, "active title": styles.TabTitleActive,
446+ "border": styles.Border, "muted text": styles.PaletteDesc,
447+ } {
448+ require.False(t, style.HasForegroundRGB, "%s must use an indexed foreground", name)
449+ require.Equal(t, ansiBrightBlue, style.Foreground, name)
450+ require.Equal(t, -1, style.Background, "%s must not use an indexed background", name)
451+ }
452+}
453+
454+func TestPaletteDisabledAndForcedModesPreserveExistingStyles(t *testing.T) {
455+ palette := [16]renderer.RGB{4: {B: 255}, 12: {R: 255, G: 255, B: 255}}
456+ usableTheme := Theme{Foreground: renderer.RGB{R: 200, G: 200, B: 200}, Background: renderer.RGB{R: 100, G: 100, B: 100}, HasFG: true, HasBG: true, Known: true, TrueColor: true}
457+ withDisabledPalette := usableTheme
458+ withDisabledPalette.Palette = palette
459+ withDisabledPalette.PaletteKnown = 1<<4 | 1<<12
460+ withDisabledPalette.UsePalette = false
461+
462+ require.Equal(t, NewStyles(usableTheme), NewStyles(withDisabledPalette))
463+
464+ forced := Theme{Known: true, SchemeKnown: true, UsePalette: false}
465+ require.Equal(t, NewStyles(Theme{Known: true}), NewStyles(forced))
466+}
467+
244 func TestNewStylesComposesSemanticStyles(t *testing.T) {468 func TestNewStylesComposesSemanticStyles(t *testing.T) {
245 theme := Theme{469 theme := Theme{
246 Foreground: renderer.RGB{R: 0xc8, G: 0xc8, B: 0xc8},470 Foreground: renderer.RGB{R: 0xc8, G: 0xc8, B: 0xc8},
M internal/usecase/theme/theme.go +115 -13
@@ -357,6 +357,13 @@ func StatusBarStyle(t Theme) renderer.Style {
357357 return style
358358 }
359359
360+const (
361+ ansiBlue = 4
362+ ansiBrightBlue = 12
363+ accentBlend = 0.25
364+ titleHueBlend = 0.2
365+)
366+
360367 func AccentStyle(t Theme) renderer.Style {
361368 if !usable(t) {
362369 return inverseStyle()
@@ -365,28 +372,69 @@ func AccentStyle(t Theme) renderer.Style {
365372 style.HasForegroundRGB = true
366373 style.ForegroundRGB = t.Foreground
367374 style.HasBackgroundRGB = true
368- style.BackgroundRGB = Blend(t.Background, t.Foreground, 0.25)
375+ style.BackgroundRGB = accentBackground(t)
369376 return style
370377 }
371378
379+// accentBackground uses a terminal's blue palette only when the resulting
380+// foreground/background pair remains legible. A neutral blend is always the
381+// fallback, including when palette inheritance is disabled.
382+func accentBackground(t Theme) renderer.RGB {
383+ neutral := Blend(t.Background, t.Foreground, accentBlend)
384+ if !t.UsePalette {
385+ return neutral
386+ }
387+ for _, slot := range []int{ansiBlue, ansiBrightBlue} {
388+ blue, ok := t.PaletteColor(slot)
389+ if !ok {
390+ continue
391+ }
392+ candidate := Blend(t.Background, blue, accentBlend)
393+ if ContrastRatio(t.Foreground, candidate) >= accentContrastMin {
394+ return candidate
395+ }
396+ }
397+ return neutral
398+}
399+
372400 func BorderStyle(t Theme) renderer.Style {
373- if !usable(t) {
374- return renderer.DefaultStyle()
401+ neutral := renderer.DefaultStyle()
402+ if usable(t) {
403+ neutral.HasForegroundRGB = true
404+ neutral.ForegroundRGB = Blend(t.Foreground, t.Background, 0.40)
375405 }
376- style := renderer.DefaultStyle()
377- style.HasForegroundRGB = true
378- style.ForegroundRGB = Blend(t.Foreground, t.Background, 0.40)
379- return style
406+ if !t.UsePalette {
407+ return neutral
408+ }
409+
410+ base := renderer.DefaultStyle()
411+ if usable(t) {
412+ base.HasForegroundRGB = true
413+ base.ForegroundRGB = t.Foreground
414+ base.HasBackgroundRGB = true
415+ base.BackgroundRGB = t.Background
416+ }
417+ return paletteForegroundStyle(base, neutral, t)
380418 }
381419
382420 func MutedTextStyle(t Theme) renderer.Style {
383- if !usable(t) {
384- return renderer.DefaultStyle()
421+ neutral := renderer.DefaultStyle()
422+ if usable(t) {
423+ neutral.HasForegroundRGB = true
424+ neutral.ForegroundRGB = Blend(t.Foreground, t.Background, 0.45)
385425 }
386- style := renderer.DefaultStyle()
387- style.HasForegroundRGB = true
388- style.ForegroundRGB = Blend(t.Foreground, t.Background, 0.45)
389- return style
426+ if !t.UsePalette {
427+ return neutral
428+ }
429+
430+ base := renderer.DefaultStyle()
431+ if usable(t) {
432+ base.HasForegroundRGB = true
433+ base.ForegroundRGB = t.Foreground
434+ base.HasBackgroundRGB = true
435+ base.BackgroundRGB = t.Background
436+ }
437+ return paletteForegroundStyle(base, neutral, t)
390438 }
391439
392440 // mutedVariantBlend is the fraction MutedVariantStyle blends a style's own
@@ -425,6 +473,60 @@ func MutedVariantStyle(base renderer.Style, t Theme) renderer.Style {
425473 return out
426474 }
427475
476+// PaletteMutedVariantStyle derives a title foreground from a terminal blue
477+// palette entry. Its contrast is evaluated against the title's actual base
478+// background, rather than the terminal background, because status and active
479+// title backgrounds differ.
480+func PaletteMutedVariantStyle(base renderer.Style, t Theme) renderer.Style {
481+ return paletteForegroundStyle(base, MutedVariantStyle(base, t), t)
482+}
483+
484+// paletteForegroundStyle uses palette blues for a foreground only. It never
485+// changes a background, so palette inheritance cannot introduce indexed
486+// backgrounds. If terminal RGB data is unavailable, a known color scheme can
487+// still select a best-effort ANSI foreground. A reported blue that fails
488+// contrast deliberately suppresses that indexed fallback.
489+func paletteForegroundStyle(base, neutral renderer.Style, t Theme) renderer.Style {
490+ if !t.UsePalette {
491+ return neutral
492+ }
493+
494+ knownBlue := paletteBlueKnown(t)
495+ if usable(t) && base.HasForegroundRGB && base.HasBackgroundRGB {
496+ for _, slot := range []int{ansiBlue, ansiBrightBlue} {
497+ blue, ok := t.PaletteColor(slot)
498+ if !ok {
499+ continue
500+ }
501+ candidate := Blend(base.ForegroundRGB, blue, titleHueBlend)
502+ if ContrastRatio(candidate, base.BackgroundRGB) >= accentContrastMin {
503+ out := base
504+ out.ForegroundRGB = candidate
505+ return out
506+ }
507+ }
508+ }
509+
510+ if !knownBlue && t.SchemeKnown {
511+ out := neutral
512+ out.HasForegroundRGB = false
513+ if t.Light {
514+ out.Foreground = ansiBlue
515+ } else {
516+ out.Foreground = ansiBrightBlue
517+ }
518+ return out
519+ }
520+ return neutral
521+}
522+
523+func paletteBlueKnown(t Theme) bool {
524+ if !t.UsePalette {
525+ return false
526+ }
527+ return t.PaletteKnown&(uint16(1)<<ansiBlue|uint16(1)<<ansiBrightBlue) != 0
528+}
529+
428530 const defaultDimmingPercent = 35
429531
430532 // Dimmer transforms resolved terminal colors for subdued UI states. Construct
@@ -357,6 +357,13 @@ func StatusBarStyle(t Theme) renderer.Style {
357 return style357 return style
358 }358 }
359 359
360+const (
361+ ansiBlue = 4
362+ ansiBrightBlue = 12
363+ accentBlend = 0.25
364+ titleHueBlend = 0.2
365+)
366+
360 func AccentStyle(t Theme) renderer.Style {367 func AccentStyle(t Theme) renderer.Style {
361 if !usable(t) {368 if !usable(t) {
362 return inverseStyle()369 return inverseStyle()
@@ -365,28 +372,69 @@ func AccentStyle(t Theme) renderer.Style {
365 style.HasForegroundRGB = true372 style.HasForegroundRGB = true
366 style.ForegroundRGB = t.Foreground373 style.ForegroundRGB = t.Foreground
367 style.HasBackgroundRGB = true374 style.HasBackgroundRGB = true
368- style.BackgroundRGB = Blend(t.Background, t.Foreground, 0.25)375+ style.BackgroundRGB = accentBackground(t)
369 return style376 return style
370 }377 }
371 378
379+// accentBackground uses a terminal's blue palette only when the resulting
380+// foreground/background pair remains legible. A neutral blend is always the
381+// fallback, including when palette inheritance is disabled.
382+func accentBackground(t Theme) renderer.RGB {
383+ neutral := Blend(t.Background, t.Foreground, accentBlend)
384+ if !t.UsePalette {
385+ return neutral
386+ }
387+ for _, slot := range []int{ansiBlue, ansiBrightBlue} {
388+ blue, ok := t.PaletteColor(slot)
389+ if !ok {
390+ continue
391+ }
392+ candidate := Blend(t.Background, blue, accentBlend)
393+ if ContrastRatio(t.Foreground, candidate) >= accentContrastMin {
394+ return candidate
395+ }
396+ }
397+ return neutral
398+}
399+
372 func BorderStyle(t Theme) renderer.Style {400 func BorderStyle(t Theme) renderer.Style {
373- if !usable(t) {401+ neutral := renderer.DefaultStyle()
374- return renderer.DefaultStyle()402+ if usable(t) {
403+ neutral.HasForegroundRGB = true
404+ neutral.ForegroundRGB = Blend(t.Foreground, t.Background, 0.40)
375 }405 }
376- style := renderer.DefaultStyle()406+ if !t.UsePalette {
377- style.HasForegroundRGB = true407+ return neutral
378- style.ForegroundRGB = Blend(t.Foreground, t.Background, 0.40)408+ }
379- return style409+
410+ base := renderer.DefaultStyle()
411+ if usable(t) {
412+ base.HasForegroundRGB = true
413+ base.ForegroundRGB = t.Foreground
414+ base.HasBackgroundRGB = true
415+ base.BackgroundRGB = t.Background
416+ }
417+ return paletteForegroundStyle(base, neutral, t)
380 }418 }
381 419
382 func MutedTextStyle(t Theme) renderer.Style {420 func MutedTextStyle(t Theme) renderer.Style {
383- if !usable(t) {421+ neutral := renderer.DefaultStyle()
384- return renderer.DefaultStyle()422+ if usable(t) {
423+ neutral.HasForegroundRGB = true
424+ neutral.ForegroundRGB = Blend(t.Foreground, t.Background, 0.45)
385 }425 }
386- style := renderer.DefaultStyle()426+ if !t.UsePalette {
387- style.HasForegroundRGB = true427+ return neutral
388- style.ForegroundRGB = Blend(t.Foreground, t.Background, 0.45)428+ }
389- return style429+
430+ base := renderer.DefaultStyle()
431+ if usable(t) {
432+ base.HasForegroundRGB = true
433+ base.ForegroundRGB = t.Foreground
434+ base.HasBackgroundRGB = true
435+ base.BackgroundRGB = t.Background
436+ }
437+ return paletteForegroundStyle(base, neutral, t)
390 }438 }
391 439
392 // mutedVariantBlend is the fraction MutedVariantStyle blends a style's own440 // mutedVariantBlend is the fraction MutedVariantStyle blends a style's own
@@ -425,6 +473,60 @@ func MutedVariantStyle(base renderer.Style, t Theme) renderer.Style {
425 return out473 return out
426 }474 }
427 475
476+// PaletteMutedVariantStyle derives a title foreground from a terminal blue
477+// palette entry. Its contrast is evaluated against the title's actual base
478+// background, rather than the terminal background, because status and active
479+// title backgrounds differ.
480+func PaletteMutedVariantStyle(base renderer.Style, t Theme) renderer.Style {
481+ return paletteForegroundStyle(base, MutedVariantStyle(base, t), t)
482+}
483+
484+// paletteForegroundStyle uses palette blues for a foreground only. It never
485+// changes a background, so palette inheritance cannot introduce indexed
486+// backgrounds. If terminal RGB data is unavailable, a known color scheme can
487+// still select a best-effort ANSI foreground. A reported blue that fails
488+// contrast deliberately suppresses that indexed fallback.
489+func paletteForegroundStyle(base, neutral renderer.Style, t Theme) renderer.Style {
490+ if !t.UsePalette {
491+ return neutral
492+ }
493+
494+ knownBlue := paletteBlueKnown(t)
495+ if usable(t) && base.HasForegroundRGB && base.HasBackgroundRGB {
496+ for _, slot := range []int{ansiBlue, ansiBrightBlue} {
497+ blue, ok := t.PaletteColor(slot)
498+ if !ok {
499+ continue
500+ }
501+ candidate := Blend(base.ForegroundRGB, blue, titleHueBlend)
502+ if ContrastRatio(candidate, base.BackgroundRGB) >= accentContrastMin {
503+ out := base
504+ out.ForegroundRGB = candidate
505+ return out
506+ }
507+ }
508+ }
509+
510+ if !knownBlue && t.SchemeKnown {
511+ out := neutral
512+ out.HasForegroundRGB = false
513+ if t.Light {
514+ out.Foreground = ansiBlue
515+ } else {
516+ out.Foreground = ansiBrightBlue
517+ }
518+ return out
519+ }
520+ return neutral
521+}
522+
523+func paletteBlueKnown(t Theme) bool {
524+ if !t.UsePalette {
525+ return false
526+ }
527+ return t.PaletteKnown&(uint16(1)<<ansiBlue|uint16(1)<<ansiBrightBlue) != 0
528+}
529+
428 const defaultDimmingPercent = 35530 const defaultDimmingPercent = 35
429 531
430 // Dimmer transforms resolved terminal colors for subdued UI states. Construct532 // Dimmer transforms resolved terminal colors for subdued UI states. Construct

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