detached b85f942

feat(theme): derive complete accent camaieu

commit
b85f9421e1eadb5dbdcfbc831657ec3d278d3781
author
brice · · 2026-07-18 07:54 UTC
parent
4c09134
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
7 files · +530 -493
M internal/usecase/theme/benchmark_test.go +24 -1
@@ -3,10 +3,14 @@ package theme
33 import (
44 "testing"
55
6+ "github.com/bnema/vev/internal/domain"
67 "github.com/bnema/vev/pkg/renderer"
78 )
89
9-var benchmarkStylesSink Styles
10+var (
11+ benchmarkStylesSink Styles
12+ benchmarkResolvedSink ResolvedTheme
13+)
1014
1115 func benchmarkPaletteTheme() Theme {
1216 return Theme{
@@ -46,3 +50,22 @@ func BenchmarkNewStyles(b *testing.B) {
4650 benchmarkStylesSink = NewStyles(theme)
4751 }
4852 }
53+
54+func TestResolveAllocations(t *testing.T) {
55+ theme := benchmarkPaletteTheme()
56+ allocs := testing.AllocsPerRun(1000, func() {
57+ benchmarkResolvedSink = Resolve(theme, domain.ThemeAccent{Mode: domain.ThemeAccentAuto})
58+ })
59+ if allocs != 0 {
60+ t.Fatalf("Resolve allocations/op = %v, want 0", allocs)
61+ }
62+}
63+
64+func BenchmarkResolve(b *testing.B) {
65+ theme := benchmarkPaletteTheme()
66+ policy := domain.ThemeAccent{Mode: domain.ThemeAccentAuto}
67+ b.ReportAllocs()
68+ for b.Loop() {
69+ benchmarkResolvedSink = Resolve(theme, policy)
70+ }
71+}
@@ -3,10 +3,14 @@ package theme
3 import (3 import (
4 "testing"4 "testing"
5 5
6+ "github.com/bnema/vev/internal/domain"
6 "github.com/bnema/vev/pkg/renderer"7 "github.com/bnema/vev/pkg/renderer"
7 )8 )
8 9
9-var benchmarkStylesSink Styles10+var (
11+ benchmarkStylesSink Styles
12+ benchmarkResolvedSink ResolvedTheme
13+)
10 14
11 func benchmarkPaletteTheme() Theme {15 func benchmarkPaletteTheme() Theme {
12 return Theme{16 return Theme{
@@ -46,3 +50,22 @@ func BenchmarkNewStyles(b *testing.B) {
46 benchmarkStylesSink = NewStyles(theme)50 benchmarkStylesSink = NewStyles(theme)
47 }51 }
48 }52 }
53+
54+func TestResolveAllocations(t *testing.T) {
55+ theme := benchmarkPaletteTheme()
56+ allocs := testing.AllocsPerRun(1000, func() {
57+ benchmarkResolvedSink = Resolve(theme, domain.ThemeAccent{Mode: domain.ThemeAccentAuto})
58+ })
59+ if allocs != 0 {
60+ t.Fatalf("Resolve allocations/op = %v, want 0", allocs)
61+ }
62+}
63+
64+func BenchmarkResolve(b *testing.B) {
65+ theme := benchmarkPaletteTheme()
66+ policy := domain.ThemeAccent{Mode: domain.ThemeAccentAuto}
67+ b.ReportAllocs()
68+ for b.Loop() {
69+ benchmarkResolvedSink = Resolve(theme, policy)
70+ }
71+}
A internal/usecase/theme/ramp.go +174 -0
@@ -0,0 +1,174 @@
1+package theme
2+
3+import "github.com/bnema/vev/pkg/renderer"
4+
5+const (
6+ normalTextContrast = 4.5
7+ borderContrast = 3.0
8+)
9+
10+// Ramp is the contrast-safe set of semantic terminal chrome colors.
11+type Ramp struct {
12+ SurfaceBar renderer.Style
13+ SurfaceInactive renderer.Style
14+ SurfaceRecent renderer.Style
15+ SurfaceActive renderer.Style
16+ BorderMuted renderer.Style
17+ BorderActive renderer.Style
18+
19+ background renderer.RGB
20+ accent renderer.RGB
21+ rgb bool
22+}
23+
24+// BuildRamp derives every RGB surface from the terminal background and one
25+// resolved accent. It deliberately never uses indexed colors for backgrounds.
26+func BuildRamp(t Theme, accent Accent) Ramp {
27+ if !accent.Known || accent.IndexedOnly || !usable(t) {
28+ return neutralRamp(t)
29+ }
30+
31+ bar, ok := surfaceAtOrBelow(t, accent.RGB, 8)
32+ if !ok {
33+ return neutralRamp(t)
34+ }
35+ inactive, ok := surfaceAtOrBelow(t, accent.RGB, 14)
36+ if !ok {
37+ return neutralRamp(t)
38+ }
39+ recent, ok := surfaceAtOrBelow(t, accent.RGB, 22)
40+ if !ok {
41+ return neutralRamp(t)
42+ }
43+ active, ok := surfaceAtOrBelow(t, accent.RGB, 100)
44+ if !ok {
45+ return neutralRamp(t)
46+ }
47+
48+ return Ramp{
49+ SurfaceBar: bar,
50+ SurfaceInactive: inactive,
51+ SurfaceRecent: recent,
52+ SurfaceActive: active,
53+ BorderMuted: mutedBorder(t, accent.RGB, bar.BackgroundRGB),
54+ BorderActive: activeBorder(t, accent.RGB, bar.BackgroundRGB),
55+ background: t.Background,
56+ accent: accent.RGB,
57+ rgb: true,
58+ }
59+}
60+
61+// surfaceAtOrBelow searches from the requested intensity to neutral. The
62+// integer sequence is intentional: it makes contrast adaptation stable.
63+func surfaceAtOrBelow(t Theme, accent renderer.RGB, target int) (renderer.Style, bool) {
64+ for weight := target; weight >= 0; weight-- {
65+ background := okLabLerp(t.Background, accent, float64(weight)/100)
66+ foreground, ok := primaryText(t, background)
67+ if !ok {
68+ continue
69+ }
70+ if _, ok := secondaryText(foreground, background); !ok {
71+ continue
72+ }
73+ return rgbSurface(foreground, background), true
74+ }
75+ return renderer.Style{}, false
76+}
77+
78+func rgbSurface(foreground, background renderer.RGB) renderer.Style {
79+ style := renderer.DefaultStyle()
80+ style.HasForegroundRGB = true
81+ style.ForegroundRGB = foreground
82+ style.HasBackgroundRGB = true
83+ style.BackgroundRGB = background
84+ return style
85+}
86+
87+// primaryText chooses only the reported default foreground or background.
88+func primaryText(t Theme, surface renderer.RGB) (renderer.RGB, bool) {
89+ best := t.Foreground
90+ bestContrast := ContrastRatio(t.Foreground, surface)
91+ backgroundContrast := ContrastRatio(t.Background, surface)
92+ if backgroundContrast > bestContrast {
93+ best, bestContrast = t.Background, backgroundContrast
94+ }
95+ return best, bestContrast >= normalTextContrast
96+}
97+
98+// secondaryText mutes toward the actual surface while retaining normal-text
99+// contrast. Searching down from 40 selects the greatest permitted mute.
100+func secondaryText(primary, surface renderer.RGB) (renderer.RGB, bool) {
101+ for weight := 40; weight >= 0; weight-- {
102+ candidate := okLabLerp(primary, surface, float64(weight)/100)
103+ if ContrastRatio(candidate, surface) >= normalTextContrast {
104+ return candidate, true
105+ }
106+ }
107+ return renderer.RGB{}, false
108+}
109+
110+// mutedBorder starts at 60 percent and only moves toward the accent, choosing
111+// the lowest weight that separates from its adjacent surface.
112+func mutedBorder(t Theme, accent, adjacent renderer.RGB) renderer.Style {
113+ for weight := 60; weight <= 100; weight++ {
114+ candidate := okLabLerp(t.Background, accent, float64(weight)/100)
115+ if ContrastRatio(candidate, adjacent) >= borderContrast {
116+ return foregroundStyle(candidate)
117+ }
118+ }
119+ return neutralBorderStyle(t)
120+}
121+
122+// activeBorder starts at the accent endpoint and reduces only when needed.
123+func activeBorder(t Theme, accent, adjacent renderer.RGB) renderer.Style {
124+ for weight := 100; weight >= 0; weight-- {
125+ candidate := okLabLerp(t.Background, accent, float64(weight)/100)
126+ if ContrastRatio(candidate, adjacent) >= borderContrast {
127+ return foregroundStyle(candidate)
128+ }
129+ }
130+ return neutralBorderStyle(t)
131+}
132+
133+func foregroundStyle(color renderer.RGB) renderer.Style {
134+ style := renderer.DefaultStyle()
135+ style.HasForegroundRGB = true
136+ style.ForegroundRGB = color
137+ return style
138+}
139+
140+func neutralRamp(t Theme) Ramp {
141+ styles := neutralStyles(t)
142+ return Ramp{
143+ SurfaceBar: styles.SurfaceBar,
144+ SurfaceInactive: styles.SurfaceInactive,
145+ SurfaceRecent: styles.SurfaceRecent,
146+ SurfaceActive: styles.SurfaceActive,
147+ BorderMuted: styles.BorderMuted,
148+ BorderActive: styles.BorderActive,
149+ }
150+}
151+
152+// MRUStyle interpolates the newest entry from SurfaceRecent (22%) to 11% for
153+// the oldest entry. A singleton is therefore the full recent surface.
154+func MRUStyle(ramp Ramp, index, count int) renderer.Style {
155+ if count <= 1 || !ramp.SurfaceRecent.HasBackgroundRGB || !ramp.SurfaceBar.HasBackgroundRGB {
156+ return ramp.SurfaceRecent
157+ }
158+ if index < 0 {
159+ index = 0
160+ }
161+ if index >= count {
162+ index = count - 1
163+ }
164+ if !ramp.rgb {
165+ return ramp.SurfaceRecent
166+ }
167+ weight := 22.0 - 11.0*float64(index)/float64(count-1)
168+ background := okLabLerp(ramp.background, ramp.accent, weight/100)
169+ foreground, ok := primaryText(Theme{Foreground: ramp.SurfaceRecent.ForegroundRGB, Background: ramp.background}, background)
170+ if !ok {
171+ return ramp.SurfaceRecent
172+ }
173+ return rgbSurface(foreground, background)
174+}
@@ -0,0 +1,174 @@
1+package theme
2+
3+import "github.com/bnema/vev/pkg/renderer"
4+
5+const (
6+ normalTextContrast = 4.5
7+ borderContrast = 3.0
8+)
9+
10+// Ramp is the contrast-safe set of semantic terminal chrome colors.
11+type Ramp struct {
12+ SurfaceBar renderer.Style
13+ SurfaceInactive renderer.Style
14+ SurfaceRecent renderer.Style
15+ SurfaceActive renderer.Style
16+ BorderMuted renderer.Style
17+ BorderActive renderer.Style
18+
19+ background renderer.RGB
20+ accent renderer.RGB
21+ rgb bool
22+}
23+
24+// BuildRamp derives every RGB surface from the terminal background and one
25+// resolved accent. It deliberately never uses indexed colors for backgrounds.
26+func BuildRamp(t Theme, accent Accent) Ramp {
27+ if !accent.Known || accent.IndexedOnly || !usable(t) {
28+ return neutralRamp(t)
29+ }
30+
31+ bar, ok := surfaceAtOrBelow(t, accent.RGB, 8)
32+ if !ok {
33+ return neutralRamp(t)
34+ }
35+ inactive, ok := surfaceAtOrBelow(t, accent.RGB, 14)
36+ if !ok {
37+ return neutralRamp(t)
38+ }
39+ recent, ok := surfaceAtOrBelow(t, accent.RGB, 22)
40+ if !ok {
41+ return neutralRamp(t)
42+ }
43+ active, ok := surfaceAtOrBelow(t, accent.RGB, 100)
44+ if !ok {
45+ return neutralRamp(t)
46+ }
47+
48+ return Ramp{
49+ SurfaceBar: bar,
50+ SurfaceInactive: inactive,
51+ SurfaceRecent: recent,
52+ SurfaceActive: active,
53+ BorderMuted: mutedBorder(t, accent.RGB, bar.BackgroundRGB),
54+ BorderActive: activeBorder(t, accent.RGB, bar.BackgroundRGB),
55+ background: t.Background,
56+ accent: accent.RGB,
57+ rgb: true,
58+ }
59+}
60+
61+// surfaceAtOrBelow searches from the requested intensity to neutral. The
62+// integer sequence is intentional: it makes contrast adaptation stable.
63+func surfaceAtOrBelow(t Theme, accent renderer.RGB, target int) (renderer.Style, bool) {
64+ for weight := target; weight >= 0; weight-- {
65+ background := okLabLerp(t.Background, accent, float64(weight)/100)
66+ foreground, ok := primaryText(t, background)
67+ if !ok {
68+ continue
69+ }
70+ if _, ok := secondaryText(foreground, background); !ok {
71+ continue
72+ }
73+ return rgbSurface(foreground, background), true
74+ }
75+ return renderer.Style{}, false
76+}
77+
78+func rgbSurface(foreground, background renderer.RGB) renderer.Style {
79+ style := renderer.DefaultStyle()
80+ style.HasForegroundRGB = true
81+ style.ForegroundRGB = foreground
82+ style.HasBackgroundRGB = true
83+ style.BackgroundRGB = background
84+ return style
85+}
86+
87+// primaryText chooses only the reported default foreground or background.
88+func primaryText(t Theme, surface renderer.RGB) (renderer.RGB, bool) {
89+ best := t.Foreground
90+ bestContrast := ContrastRatio(t.Foreground, surface)
91+ backgroundContrast := ContrastRatio(t.Background, surface)
92+ if backgroundContrast > bestContrast {
93+ best, bestContrast = t.Background, backgroundContrast
94+ }
95+ return best, bestContrast >= normalTextContrast
96+}
97+
98+// secondaryText mutes toward the actual surface while retaining normal-text
99+// contrast. Searching down from 40 selects the greatest permitted mute.
100+func secondaryText(primary, surface renderer.RGB) (renderer.RGB, bool) {
101+ for weight := 40; weight >= 0; weight-- {
102+ candidate := okLabLerp(primary, surface, float64(weight)/100)
103+ if ContrastRatio(candidate, surface) >= normalTextContrast {
104+ return candidate, true
105+ }
106+ }
107+ return renderer.RGB{}, false
108+}
109+
110+// mutedBorder starts at 60 percent and only moves toward the accent, choosing
111+// the lowest weight that separates from its adjacent surface.
112+func mutedBorder(t Theme, accent, adjacent renderer.RGB) renderer.Style {
113+ for weight := 60; weight <= 100; weight++ {
114+ candidate := okLabLerp(t.Background, accent, float64(weight)/100)
115+ if ContrastRatio(candidate, adjacent) >= borderContrast {
116+ return foregroundStyle(candidate)
117+ }
118+ }
119+ return neutralBorderStyle(t)
120+}
121+
122+// activeBorder starts at the accent endpoint and reduces only when needed.
123+func activeBorder(t Theme, accent, adjacent renderer.RGB) renderer.Style {
124+ for weight := 100; weight >= 0; weight-- {
125+ candidate := okLabLerp(t.Background, accent, float64(weight)/100)
126+ if ContrastRatio(candidate, adjacent) >= borderContrast {
127+ return foregroundStyle(candidate)
128+ }
129+ }
130+ return neutralBorderStyle(t)
131+}
132+
133+func foregroundStyle(color renderer.RGB) renderer.Style {
134+ style := renderer.DefaultStyle()
135+ style.HasForegroundRGB = true
136+ style.ForegroundRGB = color
137+ return style
138+}
139+
140+func neutralRamp(t Theme) Ramp {
141+ styles := neutralStyles(t)
142+ return Ramp{
143+ SurfaceBar: styles.SurfaceBar,
144+ SurfaceInactive: styles.SurfaceInactive,
145+ SurfaceRecent: styles.SurfaceRecent,
146+ SurfaceActive: styles.SurfaceActive,
147+ BorderMuted: styles.BorderMuted,
148+ BorderActive: styles.BorderActive,
149+ }
150+}
151+
152+// MRUStyle interpolates the newest entry from SurfaceRecent (22%) to 11% for
153+// the oldest entry. A singleton is therefore the full recent surface.
154+func MRUStyle(ramp Ramp, index, count int) renderer.Style {
155+ if count <= 1 || !ramp.SurfaceRecent.HasBackgroundRGB || !ramp.SurfaceBar.HasBackgroundRGB {
156+ return ramp.SurfaceRecent
157+ }
158+ if index < 0 {
159+ index = 0
160+ }
161+ if index >= count {
162+ index = count - 1
163+ }
164+ if !ramp.rgb {
165+ return ramp.SurfaceRecent
166+ }
167+ weight := 22.0 - 11.0*float64(index)/float64(count-1)
168+ background := okLabLerp(ramp.background, ramp.accent, weight/100)
169+ foreground, ok := primaryText(Theme{Foreground: ramp.SurfaceRecent.ForegroundRGB, Background: ramp.background}, background)
170+ if !ok {
171+ return ramp.SurfaceRecent
172+ }
173+ return rgbSurface(foreground, background)
174+}
A internal/usecase/theme/ramp_test.go +134 -0
@@ -0,0 +1,134 @@
1+package theme
2+
3+import (
4+ "testing"
5+
6+ "github.com/bnema/vev/internal/domain"
7+ "github.com/bnema/vev/pkg/renderer"
8+ "github.com/stretchr/testify/require"
9+)
10+
11+func TestBuildRampUsesExactSemanticWeights(t *testing.T) {
12+ theme := rampTheme(false)
13+ accent := Accent{RGB: renderer.RGB{R: 0x7d, G: 0xb5, B: 0xb5}, Slot: 2, Known: true}
14+ ramp := BuildRamp(theme, accent)
15+
16+ for name, got := range map[string]renderer.Style{
17+ "bar": ramp.SurfaceBar,
18+ "inactive": ramp.SurfaceInactive,
19+ "recent": ramp.SurfaceRecent,
20+ } {
21+ require.True(t, got.HasBackgroundRGB, name)
22+ }
23+ require.Equal(t, okLabLerp(theme.Background, accent.RGB, 0.08), ramp.SurfaceBar.BackgroundRGB)
24+ require.Equal(t, okLabLerp(theme.Background, accent.RGB, 0.14), ramp.SurfaceInactive.BackgroundRGB)
25+ require.Equal(t, okLabLerp(theme.Background, accent.RGB, 0.22), ramp.SurfaceRecent.BackgroundRGB)
26+ require.Equal(t, accent.RGB, ramp.SurfaceActive.BackgroundRGB)
27+ require.Equal(t, okLabLerp(theme.Background, accent.RGB, 0.60), ramp.BorderMuted.ForegroundRGB)
28+ require.Equal(t, accent.RGB, ramp.BorderActive.ForegroundRGB)
29+ require.False(t, ramp.BorderMuted.HasBackgroundRGB)
30+ require.False(t, ramp.BorderActive.HasBackgroundRGB)
31+}
32+
33+func TestBuildRampAdaptsTextAndBordersForDarkAndLightThemes(t *testing.T) {
34+ for _, light := range []bool{false, true} {
35+ t.Run(map[bool]string{false: "dark", true: "light"}[light], func(t *testing.T) {
36+ theme := rampTheme(light)
37+ accent := Accent{RGB: renderer.RGB{R: 0x7d, G: 0xb5, B: 0xb5}, Known: true}
38+ ramp := BuildRamp(theme, accent)
39+ for name, style := range map[string]renderer.Style{
40+ "bar": ramp.SurfaceBar, "inactive": ramp.SurfaceInactive,
41+ "recent": ramp.SurfaceRecent, "active": ramp.SurfaceActive,
42+ } {
43+ require.GreaterOrEqual(t, ContrastRatio(style.ForegroundRGB, style.BackgroundRGB), normalTextContrast, name)
44+ }
45+ for name, style := range map[string]renderer.Style{"muted": ramp.BorderMuted, "active": ramp.BorderActive} {
46+ require.GreaterOrEqual(t, ContrastRatio(style.ForegroundRGB, ramp.SurfaceBar.BackgroundRGB), borderContrast, name)
47+ }
48+ })
49+ }
50+}
51+
52+func TestBuildRampReducesActiveToHighestSafeWeight(t *testing.T) {
53+ theme := Theme{
54+ Foreground: renderer.RGB{R: 0xe0, G: 0xe0, B: 0xe0},
55+ Background: renderer.RGB{R: 0x59, G: 0x59, B: 0x59},
56+ HasFG: true, HasBG: true, TrueColor: true, Known: true, UsePalette: true,
57+ }
58+ accent := Accent{RGB: renderer.RGB{R: 0x7d, G: 0xb5, B: 0xb5}, Known: true}
59+ ramp := BuildRamp(theme, accent)
60+
61+ wantWeight := -1
62+ for weight := 100; weight >= 0; weight-- {
63+ surface := okLabLerp(theme.Background, accent.RGB, float64(weight)/100)
64+ primary, ok := primaryText(theme, surface)
65+ if !ok {
66+ continue
67+ }
68+ if _, ok := secondaryText(primary, surface); ok {
69+ wantWeight = weight
70+ break
71+ }
72+ }
73+ require.NotEqual(t, 100, wantWeight)
74+ require.Equal(t, okLabLerp(theme.Background, accent.RGB, float64(wantWeight)/100), ramp.SurfaceActive.BackgroundRGB)
75+}
76+
77+func TestMRUStyleFadesFromRecentToElevenPercent(t *testing.T) {
78+ theme := rampTheme(false)
79+ ramp := BuildRamp(theme, Accent{RGB: renderer.RGB{R: 0x7d, G: 0xb5, B: 0xb5}, Known: true})
80+ require.Equal(t, ramp.SurfaceRecent, MRUStyle(ramp, 0, 1))
81+ require.Equal(t, ramp.SurfaceRecent, MRUStyle(ramp, 0, 3))
82+ require.Equal(t, okLabLerp(theme.Background, renderer.RGB{R: 0x7d, G: 0xb5, B: 0xb5}, 0.11), MRUStyle(ramp, 2, 3).BackgroundRGB)
83+ require.NotEqual(t, ramp.SurfaceBar.BackgroundRGB, MRUStyle(ramp, 2, 3).BackgroundRGB)
84+}
85+
86+func TestResolveBuildsCompleteStylesFromOneAccent(t *testing.T) {
87+ theme := rampTheme(false)
88+ theme.Palette[2] = renderer.RGB{R: 0x7d, G: 0xb5, B: 0xb5}
89+ theme.Palette[10] = theme.Palette[2]
90+ theme.PaletteKnown = 1<<2 | 1<<10
91+ resolved := Resolve(theme, domain.ThemeAccent{Mode: domain.ThemeAccentAuto})
92+
93+ require.True(t, resolved.Accent.Known)
94+ require.Equal(t, resolved.Ramp.SurfaceBar, resolved.Styles.SurfaceBar)
95+ require.Equal(t, resolved.Ramp.SurfaceActive, resolved.Styles.SurfaceActive)
96+ require.Equal(t, resolved.Ramp.BorderActive, resolved.Styles.BorderActive)
97+ require.True(t, resolved.Styles.TabActive.Bold)
98+ require.Equal(t, resolved.Styles.SurfaceRecent, resolved.Styles.MRURecent)
99+ require.Equal(t, resolved.Styles.SurfaceBar, resolved.Styles.PickerBase)
100+ require.Equal(t, resolved.Styles.SurfaceActive.BackgroundRGB, resolved.Styles.PickerSelection.BackgroundRGB)
101+ require.True(t, resolved.Styles.PickerSelection.Bold)
102+}
103+
104+func TestResolveIndexedAndPaletteOffNeverUseAccentBackground(t *testing.T) {
105+ theme := rampTheme(false)
106+ theme.TrueColor = false
107+ theme.Palette[2] = renderer.RGB{R: 0x7d, G: 0xb5, B: 0xb5}
108+ theme.PaletteKnown = 1 << 2
109+ indexed := Resolve(theme, domain.ThemeAccent{Mode: domain.ThemeAccentSlot, Slot: 2})
110+ require.True(t, indexed.Accent.IndexedOnly)
111+ require.False(t, indexed.Styles.SurfaceActive.HasBackgroundRGB)
112+ require.Equal(t, 2, indexed.Styles.BorderActive.Foreground)
113+ require.False(t, indexed.Styles.BorderActive.HasBackgroundRGB)
114+
115+ theme.TrueColor = true
116+ theme.UsePalette = false
117+ off := Resolve(theme, domain.ThemeAccent{Mode: domain.ThemeAccentSlot, Slot: 2})
118+ require.False(t, off.Accent.Known)
119+ require.Equal(t, neutralStyles(theme), off.Styles)
120+
121+ insufficient := rampTheme(false)
122+ insufficient.Foreground = insufficient.Background
123+ insufficient.Palette[2] = renderer.RGB{R: 0x7d, G: 0xb5, B: 0xb5}
124+ insufficient.PaletteKnown = 1 << 2
125+ neutral := Resolve(insufficient, domain.ThemeAccent{Mode: domain.ThemeAccentSlot, Slot: 2})
126+ require.Equal(t, neutralStyles(insufficient), neutral.Styles)
127+}
128+
129+func rampTheme(light bool) Theme {
130+ if light {
131+ return Theme{Foreground: renderer.RGB{R: 0x20, G: 0x20, B: 0x20}, Background: renderer.RGB{R: 0xf8, G: 0xf8, B: 0xf8}, HasFG: true, HasBG: true, TrueColor: true, Known: true, UsePalette: true}
132+ }
133+ return Theme{Foreground: renderer.RGB{R: 0xd8, G: 0xdc, B: 0xe8}, Background: renderer.RGB{R: 0x08, G: 0x09, B: 0x0a}, HasFG: true, HasBG: true, TrueColor: true, Known: true, UsePalette: true}
134+}
@@ -0,0 +1,134 @@
1+package theme
2+
3+import (
4+ "testing"
5+
6+ "github.com/bnema/vev/internal/domain"
7+ "github.com/bnema/vev/pkg/renderer"
8+ "github.com/stretchr/testify/require"
9+)
10+
11+func TestBuildRampUsesExactSemanticWeights(t *testing.T) {
12+ theme := rampTheme(false)
13+ accent := Accent{RGB: renderer.RGB{R: 0x7d, G: 0xb5, B: 0xb5}, Slot: 2, Known: true}
14+ ramp := BuildRamp(theme, accent)
15+
16+ for name, got := range map[string]renderer.Style{
17+ "bar": ramp.SurfaceBar,
18+ "inactive": ramp.SurfaceInactive,
19+ "recent": ramp.SurfaceRecent,
20+ } {
21+ require.True(t, got.HasBackgroundRGB, name)
22+ }
23+ require.Equal(t, okLabLerp(theme.Background, accent.RGB, 0.08), ramp.SurfaceBar.BackgroundRGB)
24+ require.Equal(t, okLabLerp(theme.Background, accent.RGB, 0.14), ramp.SurfaceInactive.BackgroundRGB)
25+ require.Equal(t, okLabLerp(theme.Background, accent.RGB, 0.22), ramp.SurfaceRecent.BackgroundRGB)
26+ require.Equal(t, accent.RGB, ramp.SurfaceActive.BackgroundRGB)
27+ require.Equal(t, okLabLerp(theme.Background, accent.RGB, 0.60), ramp.BorderMuted.ForegroundRGB)
28+ require.Equal(t, accent.RGB, ramp.BorderActive.ForegroundRGB)
29+ require.False(t, ramp.BorderMuted.HasBackgroundRGB)
30+ require.False(t, ramp.BorderActive.HasBackgroundRGB)
31+}
32+
33+func TestBuildRampAdaptsTextAndBordersForDarkAndLightThemes(t *testing.T) {
34+ for _, light := range []bool{false, true} {
35+ t.Run(map[bool]string{false: "dark", true: "light"}[light], func(t *testing.T) {
36+ theme := rampTheme(light)
37+ accent := Accent{RGB: renderer.RGB{R: 0x7d, G: 0xb5, B: 0xb5}, Known: true}
38+ ramp := BuildRamp(theme, accent)
39+ for name, style := range map[string]renderer.Style{
40+ "bar": ramp.SurfaceBar, "inactive": ramp.SurfaceInactive,
41+ "recent": ramp.SurfaceRecent, "active": ramp.SurfaceActive,
42+ } {
43+ require.GreaterOrEqual(t, ContrastRatio(style.ForegroundRGB, style.BackgroundRGB), normalTextContrast, name)
44+ }
45+ for name, style := range map[string]renderer.Style{"muted": ramp.BorderMuted, "active": ramp.BorderActive} {
46+ require.GreaterOrEqual(t, ContrastRatio(style.ForegroundRGB, ramp.SurfaceBar.BackgroundRGB), borderContrast, name)
47+ }
48+ })
49+ }
50+}
51+
52+func TestBuildRampReducesActiveToHighestSafeWeight(t *testing.T) {
53+ theme := Theme{
54+ Foreground: renderer.RGB{R: 0xe0, G: 0xe0, B: 0xe0},
55+ Background: renderer.RGB{R: 0x59, G: 0x59, B: 0x59},
56+ HasFG: true, HasBG: true, TrueColor: true, Known: true, UsePalette: true,
57+ }
58+ accent := Accent{RGB: renderer.RGB{R: 0x7d, G: 0xb5, B: 0xb5}, Known: true}
59+ ramp := BuildRamp(theme, accent)
60+
61+ wantWeight := -1
62+ for weight := 100; weight >= 0; weight-- {
63+ surface := okLabLerp(theme.Background, accent.RGB, float64(weight)/100)
64+ primary, ok := primaryText(theme, surface)
65+ if !ok {
66+ continue
67+ }
68+ if _, ok := secondaryText(primary, surface); ok {
69+ wantWeight = weight
70+ break
71+ }
72+ }
73+ require.NotEqual(t, 100, wantWeight)
74+ require.Equal(t, okLabLerp(theme.Background, accent.RGB, float64(wantWeight)/100), ramp.SurfaceActive.BackgroundRGB)
75+}
76+
77+func TestMRUStyleFadesFromRecentToElevenPercent(t *testing.T) {
78+ theme := rampTheme(false)
79+ ramp := BuildRamp(theme, Accent{RGB: renderer.RGB{R: 0x7d, G: 0xb5, B: 0xb5}, Known: true})
80+ require.Equal(t, ramp.SurfaceRecent, MRUStyle(ramp, 0, 1))
81+ require.Equal(t, ramp.SurfaceRecent, MRUStyle(ramp, 0, 3))
82+ require.Equal(t, okLabLerp(theme.Background, renderer.RGB{R: 0x7d, G: 0xb5, B: 0xb5}, 0.11), MRUStyle(ramp, 2, 3).BackgroundRGB)
83+ require.NotEqual(t, ramp.SurfaceBar.BackgroundRGB, MRUStyle(ramp, 2, 3).BackgroundRGB)
84+}
85+
86+func TestResolveBuildsCompleteStylesFromOneAccent(t *testing.T) {
87+ theme := rampTheme(false)
88+ theme.Palette[2] = renderer.RGB{R: 0x7d, G: 0xb5, B: 0xb5}
89+ theme.Palette[10] = theme.Palette[2]
90+ theme.PaletteKnown = 1<<2 | 1<<10
91+ resolved := Resolve(theme, domain.ThemeAccent{Mode: domain.ThemeAccentAuto})
92+
93+ require.True(t, resolved.Accent.Known)
94+ require.Equal(t, resolved.Ramp.SurfaceBar, resolved.Styles.SurfaceBar)
95+ require.Equal(t, resolved.Ramp.SurfaceActive, resolved.Styles.SurfaceActive)
96+ require.Equal(t, resolved.Ramp.BorderActive, resolved.Styles.BorderActive)
97+ require.True(t, resolved.Styles.TabActive.Bold)
98+ require.Equal(t, resolved.Styles.SurfaceRecent, resolved.Styles.MRURecent)
99+ require.Equal(t, resolved.Styles.SurfaceBar, resolved.Styles.PickerBase)
100+ require.Equal(t, resolved.Styles.SurfaceActive.BackgroundRGB, resolved.Styles.PickerSelection.BackgroundRGB)
101+ require.True(t, resolved.Styles.PickerSelection.Bold)
102+}
103+
104+func TestResolveIndexedAndPaletteOffNeverUseAccentBackground(t *testing.T) {
105+ theme := rampTheme(false)
106+ theme.TrueColor = false
107+ theme.Palette[2] = renderer.RGB{R: 0x7d, G: 0xb5, B: 0xb5}
108+ theme.PaletteKnown = 1 << 2
109+ indexed := Resolve(theme, domain.ThemeAccent{Mode: domain.ThemeAccentSlot, Slot: 2})
110+ require.True(t, indexed.Accent.IndexedOnly)
111+ require.False(t, indexed.Styles.SurfaceActive.HasBackgroundRGB)
112+ require.Equal(t, 2, indexed.Styles.BorderActive.Foreground)
113+ require.False(t, indexed.Styles.BorderActive.HasBackgroundRGB)
114+
115+ theme.TrueColor = true
116+ theme.UsePalette = false
117+ off := Resolve(theme, domain.ThemeAccent{Mode: domain.ThemeAccentSlot, Slot: 2})
118+ require.False(t, off.Accent.Known)
119+ require.Equal(t, neutralStyles(theme), off.Styles)
120+
121+ insufficient := rampTheme(false)
122+ insufficient.Foreground = insufficient.Background
123+ insufficient.Palette[2] = renderer.RGB{R: 0x7d, G: 0xb5, B: 0xb5}
124+ insufficient.PaletteKnown = 1 << 2
125+ neutral := Resolve(insufficient, domain.ThemeAccent{Mode: domain.ThemeAccentSlot, Slot: 2})
126+ require.Equal(t, neutralStyles(insufficient), neutral.Styles)
127+}
128+
129+func rampTheme(light bool) Theme {
130+ if light {
131+ return Theme{Foreground: renderer.RGB{R: 0x20, G: 0x20, B: 0x20}, Background: renderer.RGB{R: 0xf8, G: 0xf8, B: 0xf8}, HasFG: true, HasBG: true, TrueColor: true, Known: true, UsePalette: true}
132+ }
133+ return Theme{Foreground: renderer.RGB{R: 0xd8, G: 0xdc, B: 0xe8}, Background: renderer.RGB{R: 0x08, G: 0x09, B: 0x0a}, HasFG: true, HasBG: true, TrueColor: true, Known: true, UsePalette: true}
134+}
A internal/usecase/theme/resolved_styles.go +153 -0
@@ -0,0 +1,153 @@
1+package theme
2+
3+import (
4+ "github.com/bnema/vev/internal/domain"
5+ "github.com/bnema/vev/pkg/renderer"
6+)
7+
8+const neutralAccentBlend = 0.25
9+
10+// ResolvedTheme is the immutable, complete chrome style snapshot produced from
11+// a terminal theme and typed accent policy.
12+type ResolvedTheme struct {
13+ Theme Theme
14+ Accent Accent
15+ Ramp Ramp
16+ Styles Styles
17+}
18+
19+// Resolve is the single entry point for terminal-owned accent inference and
20+// semantic chrome construction.
21+func Resolve(t Theme, policy domain.ThemeAccent) ResolvedTheme {
22+ accent := ResolveAccent(t, policy)
23+ ramp := BuildRamp(t, accent)
24+ styles := stylesFromRamp(t, accent, ramp)
25+ return ResolvedTheme{Theme: t, Accent: accent, Ramp: ramp, Styles: styles}
26+}
27+
28+func stylesFromRamp(t Theme, accent Accent, ramp Ramp) Styles {
29+ if !accent.Known || accent.IndexedOnly || !usable(t) || !ramp.rgb {
30+ styles := neutralStyles(t)
31+ if accent.Known && accent.IndexedOnly {
32+ styles = indexedStyles(styles, accent.Slot)
33+ }
34+ return styles
35+ }
36+
37+ inactiveTitle := secondarySurface(ramp.SurfaceInactive)
38+ activeTitle := secondarySurface(ramp.SurfaceActive)
39+ barDescription := secondarySurface(ramp.SurfaceBar)
40+ styles := Styles{
41+ SurfaceBar: ramp.SurfaceBar,
42+ SurfaceInactive: ramp.SurfaceInactive,
43+ SurfaceRecent: ramp.SurfaceRecent,
44+ SurfaceActive: ramp.SurfaceActive,
45+ BorderMuted: ramp.BorderMuted,
46+ BorderActive: ramp.BorderActive,
47+
48+ TabInactive: ramp.SurfaceInactive,
49+ TabInactiveTitle: inactiveTitle,
50+ TabActive: EmphasisStyle(ramp.SurfaceActive, t),
51+ TabActiveTitle: activeTitle,
52+ MRURecent: ramp.SurfaceRecent,
53+
54+ PickerBase: ramp.SurfaceBar,
55+ PickerSelection: EmphasisStyle(ramp.SurfaceActive, t),
56+ PickerDescription: barDescription,
57+ PickerSeparator: ramp.BorderMuted,
58+ PromptBase: ramp.SurfaceBar,
59+ CopyStatus: ramp.SurfaceBar,
60+ SearchSelection: EmphasisStyle(ramp.SurfaceActive, t),
61+ }
62+ return withLegacyAliases(styles)
63+}
64+
65+func secondarySurface(base renderer.Style) renderer.Style {
66+ if !base.HasForegroundRGB || !base.HasBackgroundRGB {
67+ return base
68+ }
69+ foreground, ok := secondaryText(base.ForegroundRGB, base.BackgroundRGB)
70+ if !ok {
71+ return base
72+ }
73+ base.ForegroundRGB = foreground
74+ return base
75+}
76+
77+func indexedStyles(styles Styles, slot uint8) Styles {
78+ styles.BorderMuted = indexedForeground(styles.BorderMuted, slot)
79+ styles.BorderActive = indexedForeground(styles.BorderActive, slot)
80+ styles.PickerSeparator = indexedForeground(styles.PickerSeparator, slot)
81+ styles.Border = styles.BorderMuted
82+ return styles
83+}
84+
85+func indexedForeground(style renderer.Style, slot uint8) renderer.Style {
86+ style.HasForegroundRGB = false
87+ style.Foreground = int(slot)
88+ style.HasBackgroundRGB = false
89+ style.Background = -1
90+ return style
91+}
92+
93+// neutralStyles deliberately preserves the pre-accent neutral hierarchy. It
94+// is used for palette-off, forced themes, incomplete defaults, and RGB-less
95+// indexed fallbacks; it never performs a palette slot lookup.
96+func neutralStyles(t Theme) Styles {
97+ status := renderer.DefaultStyle()
98+ accent := inverseStyle()
99+ border := renderer.DefaultStyle()
100+ muted := renderer.DefaultStyle()
101+ if usable(t) {
102+ status = rgbSurface(t.Foreground, Blend(t.Background, t.Foreground, 0.12))
103+ accent = rgbSurface(t.Foreground, Blend(t.Background, t.Foreground, neutralAccentBlend))
104+ border = neutralBorderStyle(t)
105+ muted = foregroundStyle(Blend(t.Foreground, t.Background, 0.45))
106+ }
107+ styles := Styles{
108+ SurfaceBar: status,
109+ SurfaceInactive: status,
110+ SurfaceRecent: status,
111+ SurfaceActive: accent,
112+ BorderMuted: border,
113+ BorderActive: border,
114+
115+ TabInactive: EmphasisStyle(status, t),
116+ TabInactiveTitle: MutedVariantStyle(status, t),
117+ TabActive: EmphasisStyle(accent, t),
118+ TabActiveTitle: MutedVariantStyle(accent, t),
119+ MRURecent: status,
120+
121+ PickerBase: status,
122+ PickerSelection: EmphasisStyle(accent, t),
123+ PickerDescription: muted,
124+ PickerSeparator: muted,
125+ PromptBase: status,
126+ CopyStatus: accent,
127+ SearchSelection: accent,
128+ }
129+ return withLegacyAliases(styles)
130+}
131+
132+func neutralBorderStyle(t Theme) renderer.Style {
133+ if !usable(t) {
134+ return renderer.DefaultStyle()
135+ }
136+ return foregroundStyle(Blend(t.Foreground, t.Background, 0.40))
137+}
138+
139+func withLegacyAliases(styles Styles) Styles {
140+ styles.StatusBar = styles.SurfaceBar
141+ styles.Accent = styles.SurfaceActive
142+ styles.Border = styles.BorderMuted
143+ styles.Selection = styles.SurfaceActive
144+ styles.PaletteDesc = styles.PickerDescription
145+ styles.TabName = styles.TabInactive
146+ styles.TabNameActive = styles.TabActive
147+ styles.TabTitle = styles.TabInactiveTitle
148+ styles.TabTitleActive = styles.TabActiveTitle
149+ styles.PickerName = styles.PickerBase
150+ styles.PickerSelectionName = styles.PickerSelection
151+ styles.PickerSelectionMuted = secondarySurface(styles.PickerSelection)
152+ return styles
153+}
@@ -0,0 +1,153 @@
1+package theme
2+
3+import (
4+ "github.com/bnema/vev/internal/domain"
5+ "github.com/bnema/vev/pkg/renderer"
6+)
7+
8+const neutralAccentBlend = 0.25
9+
10+// ResolvedTheme is the immutable, complete chrome style snapshot produced from
11+// a terminal theme and typed accent policy.
12+type ResolvedTheme struct {
13+ Theme Theme
14+ Accent Accent
15+ Ramp Ramp
16+ Styles Styles
17+}
18+
19+// Resolve is the single entry point for terminal-owned accent inference and
20+// semantic chrome construction.
21+func Resolve(t Theme, policy domain.ThemeAccent) ResolvedTheme {
22+ accent := ResolveAccent(t, policy)
23+ ramp := BuildRamp(t, accent)
24+ styles := stylesFromRamp(t, accent, ramp)
25+ return ResolvedTheme{Theme: t, Accent: accent, Ramp: ramp, Styles: styles}
26+}
27+
28+func stylesFromRamp(t Theme, accent Accent, ramp Ramp) Styles {
29+ if !accent.Known || accent.IndexedOnly || !usable(t) || !ramp.rgb {
30+ styles := neutralStyles(t)
31+ if accent.Known && accent.IndexedOnly {
32+ styles = indexedStyles(styles, accent.Slot)
33+ }
34+ return styles
35+ }
36+
37+ inactiveTitle := secondarySurface(ramp.SurfaceInactive)
38+ activeTitle := secondarySurface(ramp.SurfaceActive)
39+ barDescription := secondarySurface(ramp.SurfaceBar)
40+ styles := Styles{
41+ SurfaceBar: ramp.SurfaceBar,
42+ SurfaceInactive: ramp.SurfaceInactive,
43+ SurfaceRecent: ramp.SurfaceRecent,
44+ SurfaceActive: ramp.SurfaceActive,
45+ BorderMuted: ramp.BorderMuted,
46+ BorderActive: ramp.BorderActive,
47+
48+ TabInactive: ramp.SurfaceInactive,
49+ TabInactiveTitle: inactiveTitle,
50+ TabActive: EmphasisStyle(ramp.SurfaceActive, t),
51+ TabActiveTitle: activeTitle,
52+ MRURecent: ramp.SurfaceRecent,
53+
54+ PickerBase: ramp.SurfaceBar,
55+ PickerSelection: EmphasisStyle(ramp.SurfaceActive, t),
56+ PickerDescription: barDescription,
57+ PickerSeparator: ramp.BorderMuted,
58+ PromptBase: ramp.SurfaceBar,
59+ CopyStatus: ramp.SurfaceBar,
60+ SearchSelection: EmphasisStyle(ramp.SurfaceActive, t),
61+ }
62+ return withLegacyAliases(styles)
63+}
64+
65+func secondarySurface(base renderer.Style) renderer.Style {
66+ if !base.HasForegroundRGB || !base.HasBackgroundRGB {
67+ return base
68+ }
69+ foreground, ok := secondaryText(base.ForegroundRGB, base.BackgroundRGB)
70+ if !ok {
71+ return base
72+ }
73+ base.ForegroundRGB = foreground
74+ return base
75+}
76+
77+func indexedStyles(styles Styles, slot uint8) Styles {
78+ styles.BorderMuted = indexedForeground(styles.BorderMuted, slot)
79+ styles.BorderActive = indexedForeground(styles.BorderActive, slot)
80+ styles.PickerSeparator = indexedForeground(styles.PickerSeparator, slot)
81+ styles.Border = styles.BorderMuted
82+ return styles
83+}
84+
85+func indexedForeground(style renderer.Style, slot uint8) renderer.Style {
86+ style.HasForegroundRGB = false
87+ style.Foreground = int(slot)
88+ style.HasBackgroundRGB = false
89+ style.Background = -1
90+ return style
91+}
92+
93+// neutralStyles deliberately preserves the pre-accent neutral hierarchy. It
94+// is used for palette-off, forced themes, incomplete defaults, and RGB-less
95+// indexed fallbacks; it never performs a palette slot lookup.
96+func neutralStyles(t Theme) Styles {
97+ status := renderer.DefaultStyle()
98+ accent := inverseStyle()
99+ border := renderer.DefaultStyle()
100+ muted := renderer.DefaultStyle()
101+ if usable(t) {
102+ status = rgbSurface(t.Foreground, Blend(t.Background, t.Foreground, 0.12))
103+ accent = rgbSurface(t.Foreground, Blend(t.Background, t.Foreground, neutralAccentBlend))
104+ border = neutralBorderStyle(t)
105+ muted = foregroundStyle(Blend(t.Foreground, t.Background, 0.45))
106+ }
107+ styles := Styles{
108+ SurfaceBar: status,
109+ SurfaceInactive: status,
110+ SurfaceRecent: status,
111+ SurfaceActive: accent,
112+ BorderMuted: border,
113+ BorderActive: border,
114+
115+ TabInactive: EmphasisStyle(status, t),
116+ TabInactiveTitle: MutedVariantStyle(status, t),
117+ TabActive: EmphasisStyle(accent, t),
118+ TabActiveTitle: MutedVariantStyle(accent, t),
119+ MRURecent: status,
120+
121+ PickerBase: status,
122+ PickerSelection: EmphasisStyle(accent, t),
123+ PickerDescription: muted,
124+ PickerSeparator: muted,
125+ PromptBase: status,
126+ CopyStatus: accent,
127+ SearchSelection: accent,
128+ }
129+ return withLegacyAliases(styles)
130+}
131+
132+func neutralBorderStyle(t Theme) renderer.Style {
133+ if !usable(t) {
134+ return renderer.DefaultStyle()
135+ }
136+ return foregroundStyle(Blend(t.Foreground, t.Background, 0.40))
137+}
138+
139+func withLegacyAliases(styles Styles) Styles {
140+ styles.StatusBar = styles.SurfaceBar
141+ styles.Accent = styles.SurfaceActive
142+ styles.Border = styles.BorderMuted
143+ styles.Selection = styles.SurfaceActive
144+ styles.PaletteDesc = styles.PickerDescription
145+ styles.TabName = styles.TabInactive
146+ styles.TabNameActive = styles.TabActive
147+ styles.TabTitle = styles.TabInactiveTitle
148+ styles.TabTitleActive = styles.TabActiveTitle
149+ styles.PickerName = styles.PickerBase
150+ styles.PickerSelectionName = styles.PickerSelection
151+ styles.PickerSelectionMuted = secondarySurface(styles.PickerSelection)
152+ return styles
153+}
M internal/usecase/theme/styles.go +35 -34
@@ -3,6 +3,7 @@ package theme
33 import (
44 "math"
55
6+ "github.com/bnema/vev/internal/domain"
67 "github.com/bnema/vev/pkg/renderer"
78 )
89
@@ -58,47 +59,47 @@ func MRUFade(base renderer.Style, t Theme, i, count int) renderer.Style {
5859 return base
5960 }
6061
61-// Styles is the resolved set of terminal chrome styles for a client theme.
62+// Styles is the complete semantic set of terminal chrome styles. The legacy
63+// aliases remain until daemon renderers consume the semantic fields directly.
6264 type Styles struct {
63- StatusBar renderer.Style
64- Accent renderer.Style
65- Border renderer.Style
66- Selection renderer.Style
67- CopyStatus renderer.Style
68- PaletteDesc renderer.Style
65+ SurfaceBar renderer.Style
66+ SurfaceInactive renderer.Style
67+ SurfaceRecent renderer.Style
68+ SurfaceActive renderer.Style
69+ BorderMuted renderer.Style
70+ BorderActive renderer.Style
6971
70- TabName renderer.Style
71- TabNameActive renderer.Style
72- TabTitle renderer.Style
73- TabTitleActive renderer.Style
72+ TabInactive renderer.Style
73+ TabInactiveTitle renderer.Style
74+ TabActive renderer.Style
75+ TabActiveTitle renderer.Style
76+ MRURecent renderer.Style
7477
78+ PickerBase renderer.Style
79+ PickerSelection renderer.Style
80+ PickerDescription renderer.Style
81+ PickerSeparator renderer.Style
82+ PromptBase renderer.Style
83+ CopyStatus renderer.Style
84+ SearchSelection renderer.Style
85+
86+ // Compatibility aliases for pre-cache render paths.
87+ StatusBar renderer.Style
88+ Accent renderer.Style
89+ Border renderer.Style
90+ Selection renderer.Style
91+ PaletteDesc renderer.Style
92+ TabName renderer.Style
93+ TabNameActive renderer.Style
94+ TabTitle renderer.Style
95+ TabTitleActive renderer.Style
7596 PickerName renderer.Style
7697 PickerSelectionName renderer.Style
7798 PickerSelectionMuted renderer.Style
78- PickerSeparator renderer.Style
7999 }
80100
81-// NewStyles composes the terminal chrome styles from the semantic theme styles.
101+// NewStyles applies the default automatic policy. New callers that own a
102+// policy should use Resolve and retain its complete immutable result.
82103 func NewStyles(t Theme) Styles {
83- statusBar := StatusBarStyle(t)
84- accent := AccentStyle(t)
85- selection := SelectionStyle(t)
86- return Styles{
87- StatusBar: statusBar,
88- Accent: accent,
89- Border: BorderStyle(t),
90- Selection: selection,
91- CopyStatus: selection,
92- PaletteDesc: MutedTextStyle(t),
93-
94- TabName: EmphasisStyle(statusBar, t),
95- TabNameActive: EmphasisStyle(accent, t),
96- TabTitle: PaletteMutedVariantStyle(statusBar, t),
97- TabTitleActive: PaletteMutedVariantStyle(accent, t),
98-
99- PickerName: EmphasisStyle(renderer.DefaultStyle(), t),
100- PickerSelectionName: EmphasisStyle(selection, t),
101- PickerSelectionMuted: PaletteMutedVariantStyle(selection, t),
102- PickerSeparator: MutedTextStyle(t),
103- }
104+ return Resolve(t, domain.ThemeAccent{Mode: domain.ThemeAccentAuto}).Styles
104105 }
@@ -3,6 +3,7 @@ package theme
3 import (3 import (
4 "math"4 "math"
5 5
6+ "github.com/bnema/vev/internal/domain"
6 "github.com/bnema/vev/pkg/renderer"7 "github.com/bnema/vev/pkg/renderer"
7 )8 )
8 9
@@ -58,47 +59,47 @@ func MRUFade(base renderer.Style, t Theme, i, count int) renderer.Style {
58 return base59 return base
59 }60 }
60 61
61-// Styles is the resolved set of terminal chrome styles for a client theme.62+// Styles is the complete semantic set of terminal chrome styles. The legacy
63+// aliases remain until daemon renderers consume the semantic fields directly.
62 type Styles struct {64 type Styles struct {
63- StatusBar renderer.Style65+ SurfaceBar renderer.Style
64- Accent renderer.Style66+ SurfaceInactive renderer.Style
65- Border renderer.Style67+ SurfaceRecent renderer.Style
66- Selection renderer.Style68+ SurfaceActive renderer.Style
67- CopyStatus renderer.Style69+ BorderMuted renderer.Style
68- PaletteDesc renderer.Style70+ BorderActive renderer.Style
69 71
70- TabName renderer.Style72+ TabInactive renderer.Style
71- TabNameActive renderer.Style73+ TabInactiveTitle renderer.Style
72- TabTitle renderer.Style74+ TabActive renderer.Style
73- TabTitleActive renderer.Style75+ TabActiveTitle renderer.Style
76+ MRURecent renderer.Style
74 77
78+ PickerBase renderer.Style
79+ PickerSelection renderer.Style
80+ PickerDescription renderer.Style
81+ PickerSeparator renderer.Style
82+ PromptBase renderer.Style
83+ CopyStatus renderer.Style
84+ SearchSelection renderer.Style
85+
86+ // Compatibility aliases for pre-cache render paths.
87+ StatusBar renderer.Style
88+ Accent renderer.Style
89+ Border renderer.Style
90+ Selection renderer.Style
91+ PaletteDesc renderer.Style
92+ TabName renderer.Style
93+ TabNameActive renderer.Style
94+ TabTitle renderer.Style
95+ TabTitleActive renderer.Style
75 PickerName renderer.Style96 PickerName renderer.Style
76 PickerSelectionName renderer.Style97 PickerSelectionName renderer.Style
77 PickerSelectionMuted renderer.Style98 PickerSelectionMuted renderer.Style
78- PickerSeparator renderer.Style
79 }99 }
80 100
81-// NewStyles composes the terminal chrome styles from the semantic theme styles.101+// NewStyles applies the default automatic policy. New callers that own a
102+// policy should use Resolve and retain its complete immutable result.
82 func NewStyles(t Theme) Styles {103 func NewStyles(t Theme) Styles {
83- statusBar := StatusBarStyle(t)104+ return Resolve(t, domain.ThemeAccent{Mode: domain.ThemeAccentAuto}).Styles
84- accent := AccentStyle(t)
85- selection := SelectionStyle(t)
86- return Styles{
87- StatusBar: statusBar,
88- Accent: accent,
89- Border: BorderStyle(t),
90- Selection: selection,
91- CopyStatus: selection,
92- PaletteDesc: MutedTextStyle(t),
93-
94- TabName: EmphasisStyle(statusBar, t),
95- TabNameActive: EmphasisStyle(accent, t),
96- TabTitle: PaletteMutedVariantStyle(statusBar, t),
97- TabTitleActive: PaletteMutedVariantStyle(accent, t),
98-
99- PickerName: EmphasisStyle(renderer.DefaultStyle(), t),
100- PickerSelectionName: EmphasisStyle(selection, t),
101- PickerSelectionMuted: PaletteMutedVariantStyle(selection, t),
102- PickerSeparator: MutedTextStyle(t),
103- }
104 }105 }
M internal/usecase/theme/styles_test.go +0 -323
@@ -254,326 +254,3 @@ func TestThemeIsComparable(t *testing.T) {
254254 themes := map[Theme]bool{BuiltinDark: true}
255255 require.True(t, themes[BuiltinDark])
256256 }
257-
258-func TestPaletteAccentBackground(t *testing.T) {
259- base := Theme{
260- Foreground: renderer.RGB{R: 200, G: 200, B: 200},
261- Background: renderer.RGB{R: 100, G: 100, B: 100},
262- HasFG: true,
263- HasBG: true,
264- Known: true,
265- TrueColor: true,
266- UsePalette: true,
267- }
268- neutral := Blend(base.Background, base.Foreground, accentBlend)
269-
270- tests := []struct {
271- name string
272- palette [16]renderer.RGB
273- known uint16
274- usePalette bool
275- want renderer.RGB
276- wantContrastMin bool
277- }{
278- {
279- name: "slot 4 supplies a contrasting accent background",
280- palette: [16]renderer.RGB{4: {B: 255}, 12: {R: 200, G: 200, B: 200}},
281- known: 1<<4 | 1<<12,
282- usePalette: true,
283- want: Blend(base.Background, renderer.RGB{B: 255}, accentBlend),
284- wantContrastMin: true,
285- },
286- {
287- name: "slot 4 failure escalates to slot 12",
288- palette: [16]renderer.RGB{4: {R: 200, G: 200, B: 200}, 12: {}},
289- known: 1<<4 | 1<<12,
290- usePalette: true,
291- want: Blend(base.Background, renderer.RGB{}, accentBlend),
292- wantContrastMin: true,
293- },
294- {
295- name: "both palette blues failing uses exact neutral blend",
296- palette: [16]renderer.RGB{4: {R: 200, G: 200, B: 200}, 12: {R: 200, G: 200, B: 200}},
297- known: 1<<4 | 1<<12,
298- usePalette: true,
299- want: neutral,
300- },
301- {
302- name: "missing palette blues uses exact neutral blend",
303- usePalette: true,
304- want: neutral,
305- },
306- {
307- name: "palette disabled preserves neutral accent",
308- palette: [16]renderer.RGB{4: {B: 255}},
309- known: 1 << 4,
310- usePalette: false,
311- want: neutral,
312- },
313- }
314-
315- for _, tt := range tests {
316- t.Run(tt.name, func(t *testing.T) {
317- theme := base
318- theme.Palette = tt.palette
319- theme.PaletteKnown = tt.known
320- theme.UsePalette = tt.usePalette
321-
322- got := AccentStyle(theme)
323- require.True(t, got.HasBackgroundRGB)
324- require.Equal(t, tt.want, got.BackgroundRGB)
325- if tt.wantContrastMin {
326- require.GreaterOrEqual(t, ContrastRatio(theme.Foreground, got.BackgroundRGB), accentContrastMin)
327- }
328- })
329- }
330-}
331-
332-func TestPaletteTitleHueDerivation(t *testing.T) {
333- base := Theme{
334- Foreground: renderer.RGB{R: 200, G: 200, B: 200},
335- Background: renderer.RGB{R: 100, G: 100, B: 100},
336- HasFG: true,
337- HasBG: true,
338- Known: true,
339- TrueColor: true,
340- UsePalette: true,
341- }
342-
343- tests := []struct {
344- name string
345- theme Theme
346- palette [16]renderer.RGB
347- known uint16
348- }{
349- {
350- name: "slot 4 is used when it contrasts for each title",
351- theme: Theme{
352- Foreground: renderer.RGB{R: 240, G: 240, B: 240},
353- Background: renderer.RGB{R: 50, G: 50, B: 50},
354- HasFG: true, HasBG: true, Known: true, TrueColor: true, UsePalette: true,
355- },
356- palette: [16]renderer.RGB{4: {}, 12: {R: 200, G: 200, B: 200}},
357-
358- known: 1<<4 | 1<<12,
359- },
360- {
361- name: "each title escalates independently from slot 4 to slot 12",
362- palette: [16]renderer.RGB{4: {}, 12: {R: 255, G: 255, B: 255}},
363- known: 1<<4 | 1<<12,
364- },
365- {
366- name: "both title hues failing retains neutral muted styles",
367- palette: [16]renderer.RGB{4: {R: 200, G: 200, B: 200}, 12: {R: 200, G: 200, B: 200}},
368- known: 1<<4 | 1<<12,
369- },
370- }
371-
372- for _, tt := range tests {
373- t.Run(tt.name, func(t *testing.T) {
374- theme := base
375- if tt.theme.Known {
376- theme = tt.theme
377- }
378- theme.Palette = tt.palette
379- theme.PaletteKnown = tt.known
380- styles := NewStyles(theme)
381- status := StatusBarStyle(theme)
382- accent := AccentStyle(theme)
383-
384- if tt.name == "both title hues failing retains neutral muted styles" {
385- require.True(t, styles.TabTitle.Equal(MutedVariantStyle(status, theme)))
386- require.True(t, styles.TabTitleActive.Equal(MutedVariantStyle(accent, theme)))
387- return
388- }
389-
390- require.True(t, styles.TabTitle.HasForegroundRGB)
391- require.True(t, styles.TabTitleActive.HasForegroundRGB)
392- require.GreaterOrEqual(t, ContrastRatio(styles.TabTitle.ForegroundRGB, status.BackgroundRGB), accentContrastMin)
393- require.GreaterOrEqual(t, ContrastRatio(styles.TabTitleActive.ForegroundRGB, accent.BackgroundRGB), accentContrastMin)
394-
395- if tt.name == "slot 4 is used when it contrasts for each title" {
396- require.Equal(t, Blend(theme.Foreground, theme.Palette[4], titleHueBlend), styles.TabTitle.ForegroundRGB)
397- require.Equal(t, Blend(theme.Foreground, theme.Palette[4], titleHueBlend), styles.TabTitleActive.ForegroundRGB)
398- }
399- if tt.name == "each title escalates independently from slot 4 to slot 12" {
400- require.Equal(t, Blend(theme.Foreground, theme.Palette[12], titleHueBlend), styles.TabTitle.ForegroundRGB)
401- require.Equal(t, Blend(theme.Foreground, theme.Palette[4], titleHueBlend), styles.TabTitleActive.ForegroundRGB)
402- }
403- })
404- }
405-}
406-
407-func TestPaletteForegroundStylesKeepTheirRenderedBackgrounds(t *testing.T) {
408- base := Theme{
409- Foreground: renderer.RGB{R: 240, G: 240, B: 240},
410- Background: renderer.RGB{R: 50, G: 50, B: 50},
411- HasFG: true, HasBG: true, Known: true, TrueColor: true, UsePalette: true,
412- }
413-
414- tests := []struct {
415- name string
416- theme Theme
417- wantForeground renderer.RGB
418- wantIndexed int
419- }{
420- {
421- name: "RGB palette foreground",
422- theme: func() Theme { t := base; t.PaletteKnown = 1 << ansiBlue; return t }(),
423- wantForeground: Blend(base.Foreground, renderer.RGB{}, titleHueBlend),
424- wantIndexed: -1,
425- },
426- {
427- name: "indexed foreground fallback",
428- theme: func() Theme { t := base; t.SchemeKnown = true; return t }(),
429- wantIndexed: ansiBrightBlue,
430- },
431- }
432-
433- for _, tt := range tests {
434- t.Run(tt.name, func(t *testing.T) {
435- styles := NewStyles(tt.theme)
436-
437- for name, style := range map[string]renderer.Style{
438- "border": styles.Border,
439- "muted text": styles.PaletteDesc,
440- } {
441- require.False(t, style.HasBackgroundRGB, "%s must remain foreground-only", name)
442- if tt.wantIndexed >= 0 {
443- require.False(t, style.HasForegroundRGB, "%s must use an indexed foreground", name)
444- require.Equal(t, tt.wantIndexed, style.Foreground, name)
445- } else {
446- require.True(t, style.HasForegroundRGB, "%s must use the RGB palette foreground", name)
447- require.Equal(t, tt.wantForeground, style.ForegroundRGB, name)
448- }
449- }
450-
451- status := StatusBarStyle(tt.theme)
452- accent := AccentStyle(tt.theme)
453- for name, style := range map[string]renderer.Style{
454- "title": styles.TabTitle,
455- "active title": styles.TabTitleActive,
456- } {
457- wantBackground := status.BackgroundRGB
458- if name == "active title" {
459- wantBackground = accent.BackgroundRGB
460- }
461- require.True(t, style.HasBackgroundRGB, "%s must retain its title background", name)
462- require.Equal(t, wantBackground, style.BackgroundRGB, name)
463- if tt.wantIndexed >= 0 {
464- require.False(t, style.HasForegroundRGB, "%s must use an indexed foreground", name)
465- require.Equal(t, tt.wantIndexed, style.Foreground, name)
466- } else {
467- require.True(t, style.HasForegroundRGB, "%s must use the RGB palette foreground", name)
468- require.Equal(t, tt.wantForeground, style.ForegroundRGB, name)
469- }
470- }
471- })
472- }
473-}
474-
475-func TestPaletteIndexedForegroundFallback(t *testing.T) {
476- base := Theme{UsePalette: true, SchemeKnown: true, Known: true}
477-
478- tests := []struct {
479- name string
480- theme Theme
481- want int
482- ok bool
483- }{
484- {name: "dark scheme uses bright blue", theme: base, want: 12, ok: true},
485- {name: "light scheme uses blue", theme: func() Theme { t := base; t.Light = true; return t }(), want: 4, ok: true},
486- {name: "unknown scheme remains neutral", theme: Theme{UsePalette: true, Known: true}},
487- {name: "known RGB blue that cannot be evaluated remains neutral", theme: Theme{UsePalette: true, SchemeKnown: true, PaletteKnown: 1 << 4}},
488- }
489-
490- for _, tt := range tests {
491- t.Run(tt.name, func(t *testing.T) {
492- styles := NewStyles(tt.theme)
493- baselineTheme := tt.theme
494- baselineTheme.UsePalette = false
495- baseline := NewStyles(baselineTheme)
496- for name, style := range map[string]renderer.Style{
497- "title": styles.TabTitle,
498- "active title": styles.TabTitleActive,
499- "border": styles.Border,
500- "muted text": styles.PaletteDesc,
501- } {
502- require.False(t, style.HasBackgroundRGB, "%s must never receive an indexed background", name)
503- if tt.ok {
504- require.False(t, style.HasForegroundRGB, "%s must use an indexed foreground", name)
505- require.Equal(t, tt.want, style.Foreground, name)
506- } else {
507- want := map[string]renderer.Style{
508- "title": baseline.TabTitle, "active title": baseline.TabTitleActive,
509- "border": baseline.Border, "muted text": baseline.PaletteDesc,
510- }[name]
511- require.Equal(t, want, style, name)
512- }
513- }
514- })
515- }
516-}
517-
518-func TestPaletteMissingRGBUsesOnlyIndexedTitleForeground(t *testing.T) {
519- theme := Theme{
520- Foreground: renderer.RGB{R: 240, G: 240, B: 240},
521- Background: renderer.RGB{R: 30, G: 30, B: 30},
522- HasFG: true, HasBG: true, Known: true, TrueColor: true,
523- UsePalette: true, SchemeKnown: true,
524- }
525- styles := NewStyles(theme)
526- for name, style := range map[string]renderer.Style{
527- "title": styles.TabTitle, "active title": styles.TabTitleActive,
528- "border": styles.Border, "muted text": styles.PaletteDesc,
529- } {
530- require.False(t, style.HasForegroundRGB, "%s must use an indexed foreground", name)
531- require.Equal(t, ansiBrightBlue, style.Foreground, name)
532- require.Equal(t, -1, style.Background, "%s must not use an indexed background", name)
533- }
534-}
535-
536-func TestPaletteDisabledAndForcedModesPreserveExistingStyles(t *testing.T) {
537- palette := [16]renderer.RGB{4: {B: 255}, 12: {R: 255, G: 255, B: 255}}
538- 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}
539- withDisabledPalette := usableTheme
540- withDisabledPalette.Palette = palette
541- withDisabledPalette.PaletteKnown = 1<<4 | 1<<12
542- withDisabledPalette.UsePalette = false
543-
544- require.Equal(t, NewStyles(usableTheme), NewStyles(withDisabledPalette))
545-
546- forced := Theme{Known: true, SchemeKnown: true, UsePalette: false}
547- require.Equal(t, NewStyles(Theme{Known: true}), NewStyles(forced))
548-}
549-
550-func TestNewStylesComposesSemanticStyles(t *testing.T) {
551- theme := Theme{
552- Foreground: renderer.RGB{R: 0xc8, G: 0xc8, B: 0xc8},
553- Background: renderer.RGB{R: 0x0a, G: 0x14, B: 0x1e},
554- HasFG: true,
555- HasBG: true,
556- Known: true,
557- TrueColor: true,
558- }
559-
560- styles := NewStyles(theme)
561- statusBar := StatusBarStyle(theme)
562- accent := AccentStyle(theme)
563- selection := SelectionStyle(theme)
564-
565- require.True(t, styles.StatusBar.Equal(statusBar))
566- require.True(t, styles.Accent.Equal(accent))
567- require.True(t, styles.Border.Equal(BorderStyle(theme)))
568- require.True(t, styles.Selection.Equal(selection))
569- require.True(t, styles.CopyStatus.Equal(selection))
570- require.True(t, styles.PaletteDesc.Equal(MutedTextStyle(theme)))
571- require.True(t, styles.TabName.Equal(EmphasisStyle(statusBar, theme)))
572- require.True(t, styles.TabNameActive.Equal(EmphasisStyle(accent, theme)))
573- require.True(t, styles.TabTitle.Equal(MutedVariantStyle(statusBar, theme)))
574- require.True(t, styles.TabTitleActive.Equal(MutedVariantStyle(accent, theme)))
575- require.True(t, styles.PickerName.Equal(EmphasisStyle(renderer.DefaultStyle(), theme)))
576- require.True(t, styles.PickerSelectionName.Equal(EmphasisStyle(selection, theme)))
577- require.True(t, styles.PickerSelectionMuted.Equal(MutedVariantStyle(selection, theme)))
578- require.True(t, styles.PickerSeparator.Equal(MutedTextStyle(theme)))
579-}
@@ -254,326 +254,3 @@ func TestThemeIsComparable(t *testing.T) {
254 themes := map[Theme]bool{BuiltinDark: true}254 themes := map[Theme]bool{BuiltinDark: true}
255 require.True(t, themes[BuiltinDark])255 require.True(t, themes[BuiltinDark])
256 }256 }
257-
258-func TestPaletteAccentBackground(t *testing.T) {
259- base := Theme{
260- Foreground: renderer.RGB{R: 200, G: 200, B: 200},
261- Background: renderer.RGB{R: 100, G: 100, B: 100},
262- HasFG: true,
263- HasBG: true,
264- Known: true,
265- TrueColor: true,
266- UsePalette: true,
267- }
268- neutral := Blend(base.Background, base.Foreground, accentBlend)
269-
270- tests := []struct {
271- name string
272- palette [16]renderer.RGB
273- known uint16
274- usePalette bool
275- want renderer.RGB
276- wantContrastMin bool
277- }{
278- {
279- name: "slot 4 supplies a contrasting accent background",
280- palette: [16]renderer.RGB{4: {B: 255}, 12: {R: 200, G: 200, B: 200}},
281- known: 1<<4 | 1<<12,
282- usePalette: true,
283- want: Blend(base.Background, renderer.RGB{B: 255}, accentBlend),
284- wantContrastMin: true,
285- },
286- {
287- name: "slot 4 failure escalates to slot 12",
288- palette: [16]renderer.RGB{4: {R: 200, G: 200, B: 200}, 12: {}},
289- known: 1<<4 | 1<<12,
290- usePalette: true,
291- want: Blend(base.Background, renderer.RGB{}, accentBlend),
292- wantContrastMin: true,
293- },
294- {
295- name: "both palette blues failing uses exact neutral blend",
296- palette: [16]renderer.RGB{4: {R: 200, G: 200, B: 200}, 12: {R: 200, G: 200, B: 200}},
297- known: 1<<4 | 1<<12,
298- usePalette: true,
299- want: neutral,
300- },
301- {
302- name: "missing palette blues uses exact neutral blend",
303- usePalette: true,
304- want: neutral,
305- },
306- {
307- name: "palette disabled preserves neutral accent",
308- palette: [16]renderer.RGB{4: {B: 255}},
309- known: 1 << 4,
310- usePalette: false,
311- want: neutral,
312- },
313- }
314-
315- for _, tt := range tests {
316- t.Run(tt.name, func(t *testing.T) {
317- theme := base
318- theme.Palette = tt.palette
319- theme.PaletteKnown = tt.known
320- theme.UsePalette = tt.usePalette
321-
322- got := AccentStyle(theme)
323- require.True(t, got.HasBackgroundRGB)
324- require.Equal(t, tt.want, got.BackgroundRGB)
325- if tt.wantContrastMin {
326- require.GreaterOrEqual(t, ContrastRatio(theme.Foreground, got.BackgroundRGB), accentContrastMin)
327- }
328- })
329- }
330-}
331-
332-func TestPaletteTitleHueDerivation(t *testing.T) {
333- base := Theme{
334- Foreground: renderer.RGB{R: 200, G: 200, B: 200},
335- Background: renderer.RGB{R: 100, G: 100, B: 100},
336- HasFG: true,
337- HasBG: true,
338- Known: true,
339- TrueColor: true,
340- UsePalette: true,
341- }
342-
343- tests := []struct {
344- name string
345- theme Theme
346- palette [16]renderer.RGB
347- known uint16
348- }{
349- {
350- name: "slot 4 is used when it contrasts for each title",
351- theme: Theme{
352- Foreground: renderer.RGB{R: 240, G: 240, B: 240},
353- Background: renderer.RGB{R: 50, G: 50, B: 50},
354- HasFG: true, HasBG: true, Known: true, TrueColor: true, UsePalette: true,
355- },
356- palette: [16]renderer.RGB{4: {}, 12: {R: 200, G: 200, B: 200}},
357-
358- known: 1<<4 | 1<<12,
359- },
360- {
361- name: "each title escalates independently from slot 4 to slot 12",
362- palette: [16]renderer.RGB{4: {}, 12: {R: 255, G: 255, B: 255}},
363- known: 1<<4 | 1<<12,
364- },
365- {
366- name: "both title hues failing retains neutral muted styles",
367- palette: [16]renderer.RGB{4: {R: 200, G: 200, B: 200}, 12: {R: 200, G: 200, B: 200}},
368- known: 1<<4 | 1<<12,
369- },
370- }
371-
372- for _, tt := range tests {
373- t.Run(tt.name, func(t *testing.T) {
374- theme := base
375- if tt.theme.Known {
376- theme = tt.theme
377- }
378- theme.Palette = tt.palette
379- theme.PaletteKnown = tt.known
380- styles := NewStyles(theme)
381- status := StatusBarStyle(theme)
382- accent := AccentStyle(theme)
383-
384- if tt.name == "both title hues failing retains neutral muted styles" {
385- require.True(t, styles.TabTitle.Equal(MutedVariantStyle(status, theme)))
386- require.True(t, styles.TabTitleActive.Equal(MutedVariantStyle(accent, theme)))
387- return
388- }
389-
390- require.True(t, styles.TabTitle.HasForegroundRGB)
391- require.True(t, styles.TabTitleActive.HasForegroundRGB)
392- require.GreaterOrEqual(t, ContrastRatio(styles.TabTitle.ForegroundRGB, status.BackgroundRGB), accentContrastMin)
393- require.GreaterOrEqual(t, ContrastRatio(styles.TabTitleActive.ForegroundRGB, accent.BackgroundRGB), accentContrastMin)
394-
395- if tt.name == "slot 4 is used when it contrasts for each title" {
396- require.Equal(t, Blend(theme.Foreground, theme.Palette[4], titleHueBlend), styles.TabTitle.ForegroundRGB)
397- require.Equal(t, Blend(theme.Foreground, theme.Palette[4], titleHueBlend), styles.TabTitleActive.ForegroundRGB)
398- }
399- if tt.name == "each title escalates independently from slot 4 to slot 12" {
400- require.Equal(t, Blend(theme.Foreground, theme.Palette[12], titleHueBlend), styles.TabTitle.ForegroundRGB)
401- require.Equal(t, Blend(theme.Foreground, theme.Palette[4], titleHueBlend), styles.TabTitleActive.ForegroundRGB)
402- }
403- })
404- }
405-}
406-
407-func TestPaletteForegroundStylesKeepTheirRenderedBackgrounds(t *testing.T) {
408- base := Theme{
409- Foreground: renderer.RGB{R: 240, G: 240, B: 240},
410- Background: renderer.RGB{R: 50, G: 50, B: 50},
411- HasFG: true, HasBG: true, Known: true, TrueColor: true, UsePalette: true,
412- }
413-
414- tests := []struct {
415- name string
416- theme Theme
417- wantForeground renderer.RGB
418- wantIndexed int
419- }{
420- {
421- name: "RGB palette foreground",
422- theme: func() Theme { t := base; t.PaletteKnown = 1 << ansiBlue; return t }(),
423- wantForeground: Blend(base.Foreground, renderer.RGB{}, titleHueBlend),
424- wantIndexed: -1,
425- },
426- {
427- name: "indexed foreground fallback",
428- theme: func() Theme { t := base; t.SchemeKnown = true; return t }(),
429- wantIndexed: ansiBrightBlue,
430- },
431- }
432-
433- for _, tt := range tests {
434- t.Run(tt.name, func(t *testing.T) {
435- styles := NewStyles(tt.theme)
436-
437- for name, style := range map[string]renderer.Style{
438- "border": styles.Border,
439- "muted text": styles.PaletteDesc,
440- } {
441- require.False(t, style.HasBackgroundRGB, "%s must remain foreground-only", name)
442- if tt.wantIndexed >= 0 {
443- require.False(t, style.HasForegroundRGB, "%s must use an indexed foreground", name)
444- require.Equal(t, tt.wantIndexed, style.Foreground, name)
445- } else {
446- require.True(t, style.HasForegroundRGB, "%s must use the RGB palette foreground", name)
447- require.Equal(t, tt.wantForeground, style.ForegroundRGB, name)
448- }
449- }
450-
451- status := StatusBarStyle(tt.theme)
452- accent := AccentStyle(tt.theme)
453- for name, style := range map[string]renderer.Style{
454- "title": styles.TabTitle,
455- "active title": styles.TabTitleActive,
456- } {
457- wantBackground := status.BackgroundRGB
458- if name == "active title" {
459- wantBackground = accent.BackgroundRGB
460- }
461- require.True(t, style.HasBackgroundRGB, "%s must retain its title background", name)
462- require.Equal(t, wantBackground, style.BackgroundRGB, name)
463- if tt.wantIndexed >= 0 {
464- require.False(t, style.HasForegroundRGB, "%s must use an indexed foreground", name)
465- require.Equal(t, tt.wantIndexed, style.Foreground, name)
466- } else {
467- require.True(t, style.HasForegroundRGB, "%s must use the RGB palette foreground", name)
468- require.Equal(t, tt.wantForeground, style.ForegroundRGB, name)
469- }
470- }
471- })
472- }
473-}
474-
475-func TestPaletteIndexedForegroundFallback(t *testing.T) {
476- base := Theme{UsePalette: true, SchemeKnown: true, Known: true}
477-
478- tests := []struct {
479- name string
480- theme Theme
481- want int
482- ok bool
483- }{
484- {name: "dark scheme uses bright blue", theme: base, want: 12, ok: true},
485- {name: "light scheme uses blue", theme: func() Theme { t := base; t.Light = true; return t }(), want: 4, ok: true},
486- {name: "unknown scheme remains neutral", theme: Theme{UsePalette: true, Known: true}},
487- {name: "known RGB blue that cannot be evaluated remains neutral", theme: Theme{UsePalette: true, SchemeKnown: true, PaletteKnown: 1 << 4}},
488- }
489-
490- for _, tt := range tests {
491- t.Run(tt.name, func(t *testing.T) {
492- styles := NewStyles(tt.theme)
493- baselineTheme := tt.theme
494- baselineTheme.UsePalette = false
495- baseline := NewStyles(baselineTheme)
496- for name, style := range map[string]renderer.Style{
497- "title": styles.TabTitle,
498- "active title": styles.TabTitleActive,
499- "border": styles.Border,
500- "muted text": styles.PaletteDesc,
501- } {
502- require.False(t, style.HasBackgroundRGB, "%s must never receive an indexed background", name)
503- if tt.ok {
504- require.False(t, style.HasForegroundRGB, "%s must use an indexed foreground", name)
505- require.Equal(t, tt.want, style.Foreground, name)
506- } else {
507- want := map[string]renderer.Style{
508- "title": baseline.TabTitle, "active title": baseline.TabTitleActive,
509- "border": baseline.Border, "muted text": baseline.PaletteDesc,
510- }[name]
511- require.Equal(t, want, style, name)
512- }
513- }
514- })
515- }
516-}
517-
518-func TestPaletteMissingRGBUsesOnlyIndexedTitleForeground(t *testing.T) {
519- theme := Theme{
520- Foreground: renderer.RGB{R: 240, G: 240, B: 240},
521- Background: renderer.RGB{R: 30, G: 30, B: 30},
522- HasFG: true, HasBG: true, Known: true, TrueColor: true,
523- UsePalette: true, SchemeKnown: true,
524- }
525- styles := NewStyles(theme)
526- for name, style := range map[string]renderer.Style{
527- "title": styles.TabTitle, "active title": styles.TabTitleActive,
528- "border": styles.Border, "muted text": styles.PaletteDesc,
529- } {
530- require.False(t, style.HasForegroundRGB, "%s must use an indexed foreground", name)
531- require.Equal(t, ansiBrightBlue, style.Foreground, name)
532- require.Equal(t, -1, style.Background, "%s must not use an indexed background", name)
533- }
534-}
535-
536-func TestPaletteDisabledAndForcedModesPreserveExistingStyles(t *testing.T) {
537- palette := [16]renderer.RGB{4: {B: 255}, 12: {R: 255, G: 255, B: 255}}
538- 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}
539- withDisabledPalette := usableTheme
540- withDisabledPalette.Palette = palette
541- withDisabledPalette.PaletteKnown = 1<<4 | 1<<12
542- withDisabledPalette.UsePalette = false
543-
544- require.Equal(t, NewStyles(usableTheme), NewStyles(withDisabledPalette))
545-
546- forced := Theme{Known: true, SchemeKnown: true, UsePalette: false}
547- require.Equal(t, NewStyles(Theme{Known: true}), NewStyles(forced))
548-}
549-
550-func TestNewStylesComposesSemanticStyles(t *testing.T) {
551- theme := Theme{
552- Foreground: renderer.RGB{R: 0xc8, G: 0xc8, B: 0xc8},
553- Background: renderer.RGB{R: 0x0a, G: 0x14, B: 0x1e},
554- HasFG: true,
555- HasBG: true,
556- Known: true,
557- TrueColor: true,
558- }
559-
560- styles := NewStyles(theme)
561- statusBar := StatusBarStyle(theme)
562- accent := AccentStyle(theme)
563- selection := SelectionStyle(theme)
564-
565- require.True(t, styles.StatusBar.Equal(statusBar))
566- require.True(t, styles.Accent.Equal(accent))
567- require.True(t, styles.Border.Equal(BorderStyle(theme)))
568- require.True(t, styles.Selection.Equal(selection))
569- require.True(t, styles.CopyStatus.Equal(selection))
570- require.True(t, styles.PaletteDesc.Equal(MutedTextStyle(theme)))
571- require.True(t, styles.TabName.Equal(EmphasisStyle(statusBar, theme)))
572- require.True(t, styles.TabNameActive.Equal(EmphasisStyle(accent, theme)))
573- require.True(t, styles.TabTitle.Equal(MutedVariantStyle(statusBar, theme)))
574- require.True(t, styles.TabTitleActive.Equal(MutedVariantStyle(accent, theme)))
575- require.True(t, styles.PickerName.Equal(EmphasisStyle(renderer.DefaultStyle(), theme)))
576- require.True(t, styles.PickerSelectionName.Equal(EmphasisStyle(selection, theme)))
577- require.True(t, styles.PickerSelectionMuted.Equal(MutedVariantStyle(selection, theme)))
578- require.True(t, styles.PickerSeparator.Equal(MutedTextStyle(theme)))
579-}
M internal/usecase/theme/theme.go +10 -135
@@ -356,89 +356,22 @@ func StatusBarStyle(t Theme) renderer.Style {
356356 return style
357357 }
358358
359-const (
360- ansiBlue = 4
361- ansiBrightBlue = 12
362- accentBlend = 0.25
363- titleHueBlend = 0.2
364-)
365-
359+// The compatibility helpers below retain the neutral styles for isolated
360+// callers. Accent-aware construction goes exclusively through Resolve.
366361 func AccentStyle(t Theme) renderer.Style {
367- if !usable(t) {
368- return inverseStyle()
369- }
370- style := renderer.DefaultStyle()
371- style.HasForegroundRGB = true
372- style.ForegroundRGB = t.Foreground
373- style.HasBackgroundRGB = true
374- style.BackgroundRGB = accentBackground(t)
375- return style
376-}
377-
378-// accentBackground uses a terminal's blue palette only when the resulting
379-// foreground/background pair remains legible. A neutral blend is always the
380-// fallback, including when palette inheritance is disabled.
381-func accentBackground(t Theme) renderer.RGB {
382- neutral := Blend(t.Background, t.Foreground, accentBlend)
383- if !t.UsePalette {
384- return neutral
385- }
386- for _, slot := range []int{ansiBlue, ansiBrightBlue} {
387- blue, ok := t.PaletteColor(slot)
388- if !ok {
389- continue
390- }
391- candidate := Blend(t.Background, blue, accentBlend)
392- if ContrastRatio(t.Foreground, candidate) >= accentContrastMin {
393- return candidate
394- }
395- }
396- return neutral
362+ return neutralStyles(t).SurfaceActive
397363 }
398364
399365 func BorderStyle(t Theme) renderer.Style {
400- neutral := renderer.DefaultStyle()
401- if usable(t) {
402- neutral.HasForegroundRGB = true
403- neutral.ForegroundRGB = Blend(t.Foreground, t.Background, 0.40)
404- }
405- if !t.UsePalette {
406- return neutral
407- }
408-
409- base := renderer.DefaultStyle()
410- if usable(t) {
411- base.HasForegroundRGB = true
412- base.ForegroundRGB = t.Foreground
413- base.HasBackgroundRGB = true
414- base.BackgroundRGB = t.Background
415- }
416- return paletteForegroundStyle(base, neutral, t)
366+ return neutralStyles(t).BorderMuted
417367 }
418368
419369 func MutedTextStyle(t Theme) renderer.Style {
420- neutral := renderer.DefaultStyle()
421- if usable(t) {
422- neutral.HasForegroundRGB = true
423- neutral.ForegroundRGB = Blend(t.Foreground, t.Background, 0.45)
424- }
425- if !t.UsePalette {
426- return neutral
427- }
428-
429- base := renderer.DefaultStyle()
430- if usable(t) {
431- base.HasForegroundRGB = true
432- base.ForegroundRGB = t.Foreground
433- base.HasBackgroundRGB = true
434- base.BackgroundRGB = t.Background
435- }
436- return paletteForegroundStyle(base, neutral, t)
370+ return neutralStyles(t).PickerDescription
437371 }
438372
439373 // mutedVariantBlend is the fraction MutedVariantStyle blends a style's own
440-// foreground toward its own background, subtler than MutedTextStyle's 0.45
441-// (which blends the theme's global fg/bg instead of a style's own colors).
374+// foreground toward its own background.
442375 const mutedVariantBlend = 0.4
443376
444377 // EmphasisStyle returns base with bold applied when the client theme supports
@@ -452,81 +385,23 @@ func EmphasisStyle(base renderer.Style, t Theme) renderer.Style {
452385 return out
453386 }
454387
455-// MutedVariantStyle fades base's foreground toward its background when both
456-// are RGB and the client theme supports styled output; otherwise base is
457-// returned unchanged. When base has an RGB foreground but no RGB background,
458-// it fades toward t.Background instead.
388+// MutedVariantStyle fades base's foreground toward its own background.
459389 func MutedVariantStyle(base renderer.Style, t Theme) renderer.Style {
460390 if !usable(t) || !base.HasForegroundRGB {
461391 return base
462392 }
463- bg := base.BackgroundRGB
393+ background := base.BackgroundRGB
464394 if !base.HasBackgroundRGB {
465395 if !t.HasBG {
466396 return base
467397 }
468- bg = t.Background
398+ background = t.Background
469399 }
470400 out := base
471- out.ForegroundRGB = Blend(base.ForegroundRGB, bg, mutedVariantBlend)
401+ out.ForegroundRGB = Blend(base.ForegroundRGB, background, mutedVariantBlend)
472402 return out
473403 }
474404
475-// PaletteMutedVariantStyle derives a title foreground from a terminal blue
476-// palette entry. Its contrast is evaluated against the title's actual base
477-// background, rather than the terminal background, because status and active
478-// title backgrounds differ.
479-func PaletteMutedVariantStyle(base renderer.Style, t Theme) renderer.Style {
480- return paletteForegroundStyle(base, MutedVariantStyle(base, t), t)
481-}
482-
483-// paletteForegroundStyle uses palette blues for a foreground only. It never
484-// changes a background, so palette inheritance cannot introduce indexed
485-// backgrounds. If terminal RGB data is unavailable, a known color scheme can
486-// still select a best-effort ANSI foreground. A reported blue that fails
487-// contrast deliberately suppresses that indexed fallback.
488-func paletteForegroundStyle(base, neutral renderer.Style, t Theme) renderer.Style {
489- if !t.UsePalette {
490- return neutral
491- }
492-
493- knownBlue := paletteBlueKnown(t)
494- if usable(t) && base.HasForegroundRGB && base.HasBackgroundRGB {
495- for _, slot := range []int{ansiBlue, ansiBrightBlue} {
496- blue, ok := t.PaletteColor(slot)
497- if !ok {
498- continue
499- }
500- candidate := Blend(base.ForegroundRGB, blue, titleHueBlend)
501- if ContrastRatio(candidate, base.BackgroundRGB) >= accentContrastMin {
502- out := neutral
503- out.HasForegroundRGB = true
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-
530405 const defaultDimmingPercent = 35
531406
532407 // Dimmer transforms resolved terminal colors for subdued UI states. Construct
@@ -356,89 +356,22 @@ func StatusBarStyle(t Theme) renderer.Style {
356 return style356 return style
357 }357 }
358 358
359-const (359+// The compatibility helpers below retain the neutral styles for isolated
360- ansiBlue = 4360+// callers. Accent-aware construction goes exclusively through Resolve.
361- ansiBrightBlue = 12
362- accentBlend = 0.25
363- titleHueBlend = 0.2
364-)
365-
366 func AccentStyle(t Theme) renderer.Style {361 func AccentStyle(t Theme) renderer.Style {
367- if !usable(t) {362+ return neutralStyles(t).SurfaceActive
368- return inverseStyle()
369- }
370- style := renderer.DefaultStyle()
371- style.HasForegroundRGB = true
372- style.ForegroundRGB = t.Foreground
373- style.HasBackgroundRGB = true
374- style.BackgroundRGB = accentBackground(t)
375- return style
376-}
377-
378-// accentBackground uses a terminal's blue palette only when the resulting
379-// foreground/background pair remains legible. A neutral blend is always the
380-// fallback, including when palette inheritance is disabled.
381-func accentBackground(t Theme) renderer.RGB {
382- neutral := Blend(t.Background, t.Foreground, accentBlend)
383- if !t.UsePalette {
384- return neutral
385- }
386- for _, slot := range []int{ansiBlue, ansiBrightBlue} {
387- blue, ok := t.PaletteColor(slot)
388- if !ok {
389- continue
390- }
391- candidate := Blend(t.Background, blue, accentBlend)
392- if ContrastRatio(t.Foreground, candidate) >= accentContrastMin {
393- return candidate
394- }
395- }
396- return neutral
397 }363 }
398 364
399 func BorderStyle(t Theme) renderer.Style {365 func BorderStyle(t Theme) renderer.Style {
400- neutral := renderer.DefaultStyle()366+ return neutralStyles(t).BorderMuted
401- if usable(t) {
402- neutral.HasForegroundRGB = true
403- neutral.ForegroundRGB = Blend(t.Foreground, t.Background, 0.40)
404- }
405- if !t.UsePalette {
406- return neutral
407- }
408-
409- base := renderer.DefaultStyle()
410- if usable(t) {
411- base.HasForegroundRGB = true
412- base.ForegroundRGB = t.Foreground
413- base.HasBackgroundRGB = true
414- base.BackgroundRGB = t.Background
415- }
416- return paletteForegroundStyle(base, neutral, t)
417 }367 }
418 368
419 func MutedTextStyle(t Theme) renderer.Style {369 func MutedTextStyle(t Theme) renderer.Style {
420- neutral := renderer.DefaultStyle()370+ return neutralStyles(t).PickerDescription
421- if usable(t) {
422- neutral.HasForegroundRGB = true
423- neutral.ForegroundRGB = Blend(t.Foreground, t.Background, 0.45)
424- }
425- if !t.UsePalette {
426- return neutral
427- }
428-
429- base := renderer.DefaultStyle()
430- if usable(t) {
431- base.HasForegroundRGB = true
432- base.ForegroundRGB = t.Foreground
433- base.HasBackgroundRGB = true
434- base.BackgroundRGB = t.Background
435- }
436- return paletteForegroundStyle(base, neutral, t)
437 }371 }
438 372
439 // mutedVariantBlend is the fraction MutedVariantStyle blends a style's own373 // mutedVariantBlend is the fraction MutedVariantStyle blends a style's own
440-// foreground toward its own background, subtler than MutedTextStyle's 0.45374+// foreground toward its own background.
441-// (which blends the theme's global fg/bg instead of a style's own colors).
442 const mutedVariantBlend = 0.4375 const mutedVariantBlend = 0.4
443 376
444 // EmphasisStyle returns base with bold applied when the client theme supports377 // EmphasisStyle returns base with bold applied when the client theme supports
@@ -452,81 +385,23 @@ func EmphasisStyle(base renderer.Style, t Theme) renderer.Style {
452 return out385 return out
453 }386 }
454 387
455-// MutedVariantStyle fades base's foreground toward its background when both388+// MutedVariantStyle fades base's foreground toward its own background.
456-// are RGB and the client theme supports styled output; otherwise base is
457-// returned unchanged. When base has an RGB foreground but no RGB background,
458-// it fades toward t.Background instead.
459 func MutedVariantStyle(base renderer.Style, t Theme) renderer.Style {389 func MutedVariantStyle(base renderer.Style, t Theme) renderer.Style {
460 if !usable(t) || !base.HasForegroundRGB {390 if !usable(t) || !base.HasForegroundRGB {
461 return base391 return base
462 }392 }
463- bg := base.BackgroundRGB393+ background := base.BackgroundRGB
464 if !base.HasBackgroundRGB {394 if !base.HasBackgroundRGB {
465 if !t.HasBG {395 if !t.HasBG {
466 return base396 return base
467 }397 }
468- bg = t.Background398+ background = t.Background
469 }399 }
470 out := base400 out := base
471- out.ForegroundRGB = Blend(base.ForegroundRGB, bg, mutedVariantBlend)401+ out.ForegroundRGB = Blend(base.ForegroundRGB, background, mutedVariantBlend)
472 return out402 return out
473 }403 }
474 404
475-// PaletteMutedVariantStyle derives a title foreground from a terminal blue
476-// palette entry. Its contrast is evaluated against the title's actual base
477-// background, rather than the terminal background, because status and active
478-// title backgrounds differ.
479-func PaletteMutedVariantStyle(base renderer.Style, t Theme) renderer.Style {
480- return paletteForegroundStyle(base, MutedVariantStyle(base, t), t)
481-}
482-
483-// paletteForegroundStyle uses palette blues for a foreground only. It never
484-// changes a background, so palette inheritance cannot introduce indexed
485-// backgrounds. If terminal RGB data is unavailable, a known color scheme can
486-// still select a best-effort ANSI foreground. A reported blue that fails
487-// contrast deliberately suppresses that indexed fallback.
488-func paletteForegroundStyle(base, neutral renderer.Style, t Theme) renderer.Style {
489- if !t.UsePalette {
490- return neutral
491- }
492-
493- knownBlue := paletteBlueKnown(t)
494- if usable(t) && base.HasForegroundRGB && base.HasBackgroundRGB {
495- for _, slot := range []int{ansiBlue, ansiBrightBlue} {
496- blue, ok := t.PaletteColor(slot)
497- if !ok {
498- continue
499- }
500- candidate := Blend(base.ForegroundRGB, blue, titleHueBlend)
501- if ContrastRatio(candidate, base.BackgroundRGB) >= accentContrastMin {
502- out := neutral
503- out.HasForegroundRGB = true
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-
530 const defaultDimmingPercent = 35405 const defaultDimmingPercent = 35
531 406
532 // Dimmer transforms resolved terminal colors for subdued UI states. Construct407 // 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