1package theme
2
3import (
4 "math"
5 "testing"
6
7 renderer "github.com/bnema/vev-vt"
8 "github.com/bnema/vev/internal/domain"
9 "github.com/stretchr/testify/require"
10)
11
12func TestBuildRampUsesExactSemanticWeights(t *testing.T) {
13 theme := rampTheme(false)
14 accent := Accent{RGB: renderer.RGB{R: 0x7d, G: 0xb5, B: 0xb5}, Slot: 2, Known: true}
15 ramp := BuildRamp(theme, accent)
16
17 for name, got := range map[string]renderer.Style{
18 "bar": ramp.SurfaceBar,
19 "inactive": ramp.SurfaceInactive,
20 "recent": ramp.SurfaceRecent,
21 } {
22 require.True(t, got.HasBackgroundRGB, name)
23 }
24 require.Equal(t, okLabLerp(theme.Background, accent.RGB, 0.08), ramp.SurfaceBar.BackgroundRGB)
25 require.Equal(t, okLabLerp(theme.Background, accent.RGB, 0.14), ramp.SurfaceInactive.BackgroundRGB)
26 require.Equal(t, okLabLerp(theme.Background, accent.RGB, 0.22), ramp.SurfaceRecent.BackgroundRGB)
27 require.Equal(t, accent.RGB, ramp.SurfaceActive.BackgroundRGB)
28 require.Equal(t, okLabLerp(theme.Background, accent.RGB, 0.60), ramp.BorderMuted.ForegroundRGB)
29 require.Equal(t, accent.RGB, ramp.BorderActive.ForegroundRGB)
30 require.False(t, ramp.BorderMuted.HasBackgroundRGB)
31 require.False(t, ramp.BorderActive.HasBackgroundRGB)
32}
33
34func TestBuildRampAdaptsTextAndBordersForDarkAndLightThemes(t *testing.T) {
35 for _, light := range []bool{false, true} {
36 t.Run(map[bool]string{false: "dark", true: "light"}[light], func(t *testing.T) {
37 theme := rampTheme(light)
38 accent := Accent{RGB: renderer.RGB{R: 0x7d, G: 0xb5, B: 0xb5}, Known: true}
39 ramp := BuildRamp(theme, accent)
40 for name, style := range map[string]renderer.Style{
41 "bar": ramp.SurfaceBar, "inactive": ramp.SurfaceInactive,
42 "recent": ramp.SurfaceRecent, "active": ramp.SurfaceActive,
43 } {
44 require.GreaterOrEqual(t, ContrastRatio(style.ForegroundRGB, style.BackgroundRGB), normalTextContrast, name)
45 }
46 for name, style := range map[string]renderer.Style{"muted": ramp.BorderMuted, "active": ramp.BorderActive} {
47 require.GreaterOrEqual(t, ContrastRatio(style.ForegroundRGB, ramp.SurfaceBar.BackgroundRGB), borderContrast, name)
48 }
49 })
50 }
51}
52
53func TestBuildRampReducesActiveToHighestSafeWeight(t *testing.T) {
54 theme := Theme{
55 Foreground: renderer.RGB{R: 0xe0, G: 0xe0, B: 0xe0},
56 Background: renderer.RGB{R: 0x59, G: 0x59, B: 0x59},
57 HasFG: true, HasBG: true, Known: true, UsePalette: true,
58 }
59 accent := Accent{RGB: renderer.RGB{R: 0x7d, G: 0xb5, B: 0xb5}, Known: true}
60 ramp := BuildRamp(theme, accent)
61
62 wantWeight := -1
63 for weight := 100; weight >= 0; weight-- {
64 surface := okLabLerp(theme.Background, accent.RGB, float64(weight)/100)
65 primary, ok := primaryText(theme, surface)
66 if !ok {
67 continue
68 }
69 if _, ok := secondaryText(primary, surface); ok {
70 wantWeight = weight
71 break
72 }
73 }
74 require.NotEqual(t, 100, wantWeight)
75 require.Equal(t, okLabLerp(theme.Background, accent.RGB, float64(wantWeight)/100), ramp.SurfaceActive.BackgroundRGB)
76}
77
78func TestBuildRampWarnBorderIsAmberAndDistinctAcrossAccentHues(t *testing.T) {
79 theme := rampTheme(false)
80
81 for name, accentRGB := range map[string]renderer.RGB{
82 "blue accent": {R: 0x6c, G: 0x9b, B: 0xd9},
83 "red accent": {R: 0xcc, G: 0x66, B: 0x66},
84 } {
85 t.Run(name, func(t *testing.T) {
86 accent := Accent{RGB: accentRGB, Known: true}
87 ramp := BuildRamp(theme, accent)
88
89 require.True(t, ramp.BorderWarn.HasForegroundRGB)
90 require.False(t, ramp.BorderWarn.HasBackgroundRGB)
91
92 accentHue := oklabToOKLCh(rgbToOKLab(accentRGB)).H
93 warnHue := oklabToOKLCh(rgbToOKLab(ramp.BorderWarn.ForegroundRGB)).H
94 distanceToTarget := func(h float64) float64 {
95 d := math.Abs(h - warnHueDegrees)
96 if d > 180 {
97 d = 360 - d
98 }
99 return d
100 }
101 // Warn must land measurably closer to the amber target than the
102 // accent itself sits, in both directions (blue and the
103 // adversarial case of a red accent, which sits close to zero
104 // degrees and could otherwise get confused with an
105 // under-rotated "warm" accent border).
106 require.Less(t, distanceToTarget(warnHue), distanceToTarget(accentHue))
107 require.Less(t, distanceToTarget(warnHue), 5.0)
108
109 // Distinct from the sibling borders by more than a rounding
110 // difference: use the same OKLab distance scale accent
111 // clustering already treats as "different colors"
112 // (accentClusterDistance groups colors within 0.04).
113 warnLab := rgbToOKLab(ramp.BorderWarn.ForegroundRGB)
114 mutedLab := rgbToOKLab(ramp.BorderMuted.ForegroundRGB)
115 activeLab := rgbToOKLab(ramp.BorderActive.ForegroundRGB)
116 require.Greater(t, okLabDistance(warnLab, mutedLab), accentClusterDistance)
117 require.Greater(t, okLabDistance(warnLab, activeLab), accentClusterDistance)
118
119 require.GreaterOrEqual(t, ContrastRatio(ramp.BorderWarn.ForegroundRGB, ramp.SurfaceBar.BackgroundRGB), borderContrast)
120 })
121 }
122}
123
124func TestMRUWeight(t *testing.T) {
125 tests := []struct {
126 count int
127 want []float64
128 }{
129 {count: 1, want: []float64{0.5}},
130 {count: 2, want: []float64{0.5, 0.25}},
131 {count: 3, want: []float64{0.5, 1.0 / 3, 1.0 / 6}},
132 {count: 4, want: []float64{0.5, 0.375, 0.25, 0.125}},
133 }
134 for _, tt := range tests {
135 for index, want := range tt.want {
136 require.InDelta(t, want, mruWeight(index, tt.count), 1e-9, "count=%d index=%d", tt.count, index)
137 }
138 }
139}
140
141func TestMRUStylePlacesHistoryBetweenActiveAndBar(t *testing.T) {
142 tests := []struct {
143 name string
144 theme Theme
145 accent renderer.RGB
146 }{
147 {name: "dark teal", theme: rampTheme(false), accent: renderer.RGB{R: 0x7d, G: 0xb5, B: 0xb5}},
148 {name: "dark blue", theme: rampTheme(false), accent: renderer.RGB{R: 0x3b, G: 0x82, B: 0xf6}},
149 {name: "light teal", theme: rampTheme(true), accent: renderer.RGB{R: 0x2a, G: 0x7a, B: 0x7a}},
150 {name: "low contrast grey with blue", theme: Theme{Foreground: renderer.RGB{R: 160, G: 160, B: 160}, Background: renderer.RGB{R: 32, G: 32, B: 32}, HasFG: true, HasBG: true, Known: true, UsePalette: true}, accent: renderer.RGB{R: 0, G: 102, B: 255}},
151 }
152 for _, tt := range tests {
153 t.Run(tt.name, func(t *testing.T) {
154 ramp := BuildRamp(tt.theme, Accent{RGB: tt.accent, Known: true})
155 require.True(t, ramp.rgb)
156 bar, active := ramp.SurfaceBar.BackgroundRGB, ramp.SurfaceActive.BackgroundRGB
157 for count := 1; count <= 9; count++ {
158 for index := range count {
159 weight := mruWeight(index, count)
160 style := MRUStyle(ramp, index, count)
161 require.Equal(t, okLabLerp(bar, active, weight), style.BackgroundRGB, "count=%d index=%d", count, index)
162 foreground, _ := primaryText(tt.theme, style.BackgroundRGB)
163 require.Equal(t, foreground, style.ForegroundRGB)
164 }
165 }
166 require.Equal(t, okLabLerp(bar, active, 0.5), MRUStyle(ramp, 0, 1).BackgroundRGB, "the newest entry always sits halfway")
167 })
168 }
169}
170
171func TestMRUStyleWithoutRGBKeepsRecentSurface(t *testing.T) {
172 ramp := neutralRamp(rampTheme(false))
173 require.Equal(t, ramp.SurfaceRecent, MRUStyle(ramp, 0, 3))
174}
175
176func TestResolveBuildsCompleteStylesFromOneAccent(t *testing.T) {
177 theme := rampTheme(false)
178 theme.Palette[2] = renderer.RGB{R: 0x7d, G: 0xb5, B: 0xb5}
179 theme.Palette[10] = theme.Palette[2]
180 theme.PaletteKnown = 1<<2 | 1<<10
181 resolved := Resolve(theme, domain.ThemeAccent{Mode: domain.ThemeAccentAuto})
182
183 require.True(t, resolved.Accent.Known)
184 require.Equal(t, resolved.Ramp.SurfaceBar, resolved.Styles.SurfaceBar)
185 require.Equal(t, resolved.Ramp.SurfaceActive, resolved.Styles.SurfaceActive)
186 require.Equal(t, resolved.Ramp.BorderActive, resolved.Styles.BorderActive)
187 require.Equal(t, resolved.Ramp.BorderWarn, resolved.Styles.BorderWarn)
188 require.Equal(t, foregroundStyle(Blend(theme.Foreground, theme.Background, 0.40)), resolved.Styles.NeutralBorder)
189 require.True(t, resolved.Styles.TabActive.Bold)
190 require.Equal(t, resolved.Styles.SurfaceRecent, resolved.Styles.MRURecent)
191 require.Equal(t, renderer.DefaultStyle(), resolved.Styles.PickerBase)
192 require.Equal(t, renderer.DefaultStyle(), resolved.Styles.PromptBase)
193 require.False(t, resolved.Styles.PickerDescription.HasBackgroundRGB)
194 require.False(t, resolved.Styles.PickerSeparator.HasBackgroundRGB)
195 require.Equal(t, resolved.Styles.SurfaceActive.BackgroundRGB, resolved.Styles.PickerSelection.BackgroundRGB)
196 require.True(t, resolved.Styles.PickerSelection.Bold)
197}
198
199func TestResolveANSI256AndPaletteOffSurfaces(t *testing.T) {
200 theme := rampTheme(false)
201 theme.Palette[2] = renderer.RGB{R: 0x7d, G: 0xb5, B: 0xb5}
202 theme.PaletteKnown = 1 << 2
203 indexed := Resolve(theme, domain.ThemeAccent{Mode: domain.ThemeAccentSlot, Slot: 2})
204 require.False(t, indexed.Accent.IndexedOnly)
205 for name, style := range map[string]renderer.Style{
206 "active tab": indexed.Styles.TabActive,
207 "active title": indexed.Styles.TabActiveTitle,
208 "picker selection": indexed.Styles.PickerSelection,
209 "search selection": indexed.Styles.SearchSelection,
210 } {
211 t.Run(name, func(t *testing.T) {
212 require.True(t, style.HasBackgroundRGB)
213 require.Equal(t, indexed.Styles.SurfaceActive.BackgroundRGB, style.BackgroundRGB)
214 })
215 }
216
217 theme.UsePalette = false
218 off := Resolve(theme, domain.ThemeAccent{Mode: domain.ThemeAccentSlot, Slot: 2})
219 require.False(t, off.Accent.Known)
220 require.Equal(t, neutralStyles(theme), off.Styles)
221
222 insufficient := rampTheme(false)
223 insufficient.Foreground = insufficient.Background
224 insufficient.Palette[2] = renderer.RGB{R: 0x7d, G: 0xb5, B: 0xb5}
225 insufficient.PaletteKnown = 1 << 2
226 neutral := Resolve(insufficient, domain.ThemeAccent{Mode: domain.ThemeAccentSlot, Slot: 2})
227 require.Equal(t, neutralStyles(insufficient), neutral.Styles)
228}
229
230func rampTheme(light bool) Theme {
231 if light {
232 return Theme{Foreground: renderer.RGB{R: 0x20, G: 0x20, B: 0x20}, Background: renderer.RGB{R: 0xf8, G: 0xf8, B: 0xf8}, HasFG: true, HasBG: true, Known: true, UsePalette: true}
233 }
234 return Theme{Foreground: renderer.RGB{R: 0xd8, G: 0xdc, B: 0xe8}, Background: renderer.RGB{R: 0x08, G: 0x09, B: 0x0a}, HasFG: true, HasBG: true, Known: true, UsePalette: true}
235}