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, dimText(foreground, style.BackgroundRGB, mruTextDim(index, count)), style.ForegroundRGB)
164 if ContrastRatio(foreground, style.BackgroundRGB) >= normalTextContrast {
165 require.GreaterOrEqual(t, ContrastRatio(style.ForegroundRGB, style.BackgroundRGB), normalTextContrast, "count=%d index=%d keeps normal-text contrast", count, index)
166 }
167 }
168 }
169 require.Equal(t, okLabLerp(bar, active, 0.5), MRUStyle(ramp, 0, 1).BackgroundRGB, "the newest entry always sits halfway")
170 })
171 }
172}
173
174func TestMRUTextDimFadesOldestToSeventyPercent(t *testing.T) {
175 tests := []struct {
176 count int
177 want []float64
178 }{
179 {count: 1, want: []float64{0}},
180 {count: 2, want: []float64{0, 0.3}},
181 {count: 3, want: []float64{0, 0.15, 0.3}},
182 {count: 4, want: []float64{0, 0.1, 0.2, 0.3}},
183 }
184 for _, tt := range tests {
185 for index, want := range tt.want {
186 require.InDelta(t, want, mruTextDim(index, tt.count), 1e-9, "count=%d index=%d", tt.count, index)
187 }
188 }
189}
190
191func TestDimTextStopsAtNormalTextContrast(t *testing.T) {
192 black, white, grey := renderer.RGB{}, renderer.RGB{R: 255, G: 255, B: 255}, renderer.RGB{R: 0x80, G: 0x80, B: 0x80}
193 tests := []struct {
194 name string
195 text renderer.RGB
196 surface renderer.RGB
197 want renderer.RGB
198 }{
199 {name: "high contrast fades fully", text: white, surface: black, want: okLabLerp(white, black, 0.3)},
200 {name: "marginal contrast fades only to the floor", text: grey, surface: black},
201 {name: "low contrast stays undimmed", text: grey, surface: grey, want: grey},
202 }
203 for _, tt := range tests {
204 t.Run(tt.name, func(t *testing.T) {
205 got := dimText(tt.text, tt.surface, 0.3)
206 if tt.want != (renderer.RGB{}) {
207 require.Equal(t, tt.want, got)
208 }
209 if ContrastRatio(tt.text, tt.surface) >= normalTextContrast {
210 require.GreaterOrEqual(t, ContrastRatio(got, tt.surface), normalTextContrast)
211 }
212 })
213 }
214}
215
216func TestMRUStyleWithoutRGBKeepsRecentSurface(t *testing.T) {
217 ramp := neutralRamp(rampTheme(false))
218 require.Equal(t, ramp.SurfaceRecent, MRUStyle(ramp, 0, 3))
219}
220
221func TestResolveBuildsCompleteStylesFromOneAccent(t *testing.T) {
222 theme := rampTheme(false)
223 theme.Palette[2] = renderer.RGB{R: 0x7d, G: 0xb5, B: 0xb5}
224 theme.Palette[10] = theme.Palette[2]
225 theme.PaletteKnown = 1<<2 | 1<<10
226 resolved := Resolve(theme, domain.ThemeAccent{Mode: domain.ThemeAccentAuto})
227
228 require.True(t, resolved.Accent.Known)
229 require.Equal(t, resolved.Ramp.SurfaceBar, resolved.Styles.SurfaceBar)
230 require.Equal(t, resolved.Ramp.SurfaceActive, resolved.Styles.SurfaceActive)
231 require.Equal(t, resolved.Ramp.BorderActive, resolved.Styles.BorderActive)
232 require.Equal(t, resolved.Ramp.BorderWarn, resolved.Styles.BorderWarn)
233 require.Equal(t, foregroundStyle(Blend(theme.Foreground, theme.Background, 0.40)), resolved.Styles.NeutralBorder)
234 require.True(t, resolved.Styles.TabActive.Bold)
235 require.Equal(t, resolved.Styles.SurfaceRecent, resolved.Styles.MRURecent)
236 require.Equal(t, renderer.DefaultStyle(), resolved.Styles.PickerBase)
237 require.Equal(t, renderer.DefaultStyle(), resolved.Styles.PromptBase)
238 require.False(t, resolved.Styles.PickerDescription.HasBackgroundRGB)
239 require.False(t, resolved.Styles.PickerSeparator.HasBackgroundRGB)
240 require.Equal(t, resolved.Styles.SurfaceActive.BackgroundRGB, resolved.Styles.PickerSelection.BackgroundRGB)
241 require.True(t, resolved.Styles.PickerSelection.Bold)
242}
243
244func TestResolveANSI256AndPaletteOffSurfaces(t *testing.T) {
245 theme := rampTheme(false)
246 theme.Palette[2] = renderer.RGB{R: 0x7d, G: 0xb5, B: 0xb5}
247 theme.PaletteKnown = 1 << 2
248 indexed := Resolve(theme, domain.ThemeAccent{Mode: domain.ThemeAccentSlot, Slot: 2})
249 require.False(t, indexed.Accent.IndexedOnly)
250 for name, style := range map[string]renderer.Style{
251 "active tab": indexed.Styles.TabActive,
252 "active title": indexed.Styles.TabActiveTitle,
253 "picker selection": indexed.Styles.PickerSelection,
254 "search selection": indexed.Styles.SearchSelection,
255 } {
256 t.Run(name, func(t *testing.T) {
257 require.True(t, style.HasBackgroundRGB)
258 require.Equal(t, indexed.Styles.SurfaceActive.BackgroundRGB, style.BackgroundRGB)
259 })
260 }
261
262 theme.UsePalette = false
263 off := Resolve(theme, domain.ThemeAccent{Mode: domain.ThemeAccentSlot, Slot: 2})
264 require.False(t, off.Accent.Known)
265 require.Equal(t, neutralStyles(theme), off.Styles)
266
267 insufficient := rampTheme(false)
268 insufficient.Foreground = insufficient.Background
269 insufficient.Palette[2] = renderer.RGB{R: 0x7d, G: 0xb5, B: 0xb5}
270 insufficient.PaletteKnown = 1 << 2
271 neutral := Resolve(insufficient, domain.ThemeAccent{Mode: domain.ThemeAccentSlot, Slot: 2})
272 require.Equal(t, neutralStyles(insufficient), neutral.Styles)
273}
274
275func rampTheme(light bool) Theme {
276 if light {
277 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}
278 }
279 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}
280}