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 TestMRUStyleFadesFromRecentToElevenPercent(t *testing.T) {
125 theme := rampTheme(false)
126 ramp := BuildRamp(theme, Accent{RGB: renderer.RGB{R: 0x7d, G: 0xb5, B: 0xb5}, Known: true})
127 require.Equal(t, ramp.SurfaceRecent, MRUStyle(ramp, 0, 1))
128 require.Equal(t, ramp.SurfaceRecent, MRUStyle(ramp, 0, 3))
129 require.Equal(t, okLabLerp(theme.Background, renderer.RGB{R: 0x7d, G: 0xb5, B: 0xb5}, 0.11), MRUStyle(ramp, 2, 3).BackgroundRGB)
130 require.NotEqual(t, ramp.SurfaceBar.BackgroundRGB, MRUStyle(ramp, 2, 3).BackgroundRGB)
131}
132
133func TestResolveBuildsCompleteStylesFromOneAccent(t *testing.T) {
134 theme := rampTheme(false)
135 theme.Palette[2] = renderer.RGB{R: 0x7d, G: 0xb5, B: 0xb5}
136 theme.Palette[10] = theme.Palette[2]
137 theme.PaletteKnown = 1<<2 | 1<<10
138 resolved := Resolve(theme, domain.ThemeAccent{Mode: domain.ThemeAccentAuto})
139
140 require.True(t, resolved.Accent.Known)
141 require.Equal(t, resolved.Ramp.SurfaceBar, resolved.Styles.SurfaceBar)
142 require.Equal(t, resolved.Ramp.SurfaceActive, resolved.Styles.SurfaceActive)
143 require.Equal(t, resolved.Ramp.BorderActive, resolved.Styles.BorderActive)
144 require.Equal(t, resolved.Ramp.BorderWarn, resolved.Styles.BorderWarn)
145 require.Equal(t, foregroundStyle(Blend(theme.Foreground, theme.Background, 0.40)), resolved.Styles.NeutralBorder)
146 require.True(t, resolved.Styles.TabActive.Bold)
147 require.Equal(t, resolved.Styles.SurfaceRecent, resolved.Styles.MRURecent)
148 require.Equal(t, renderer.DefaultStyle(), resolved.Styles.PickerBase)
149 require.Equal(t, renderer.DefaultStyle(), resolved.Styles.PromptBase)
150 require.False(t, resolved.Styles.PickerDescription.HasBackgroundRGB)
151 require.False(t, resolved.Styles.PickerSeparator.HasBackgroundRGB)
152 require.Equal(t, resolved.Styles.SurfaceActive.BackgroundRGB, resolved.Styles.PickerSelection.BackgroundRGB)
153 require.True(t, resolved.Styles.PickerSelection.Bold)
154}
155
156func TestResolveANSI256AndPaletteOffSurfaces(t *testing.T) {
157 theme := rampTheme(false)
158 theme.Palette[2] = renderer.RGB{R: 0x7d, G: 0xb5, B: 0xb5}
159 theme.PaletteKnown = 1 << 2
160 indexed := Resolve(theme, domain.ThemeAccent{Mode: domain.ThemeAccentSlot, Slot: 2})
161 require.False(t, indexed.Accent.IndexedOnly)
162 for name, style := range map[string]renderer.Style{
163 "active tab": indexed.Styles.TabActive,
164 "active title": indexed.Styles.TabActiveTitle,
165 "picker selection": indexed.Styles.PickerSelection,
166 "search selection": indexed.Styles.SearchSelection,
167 } {
168 t.Run(name, func(t *testing.T) {
169 require.True(t, style.HasBackgroundRGB)
170 require.Equal(t, indexed.Styles.SurfaceActive.BackgroundRGB, style.BackgroundRGB)
171 })
172 }
173
174 theme.UsePalette = false
175 off := Resolve(theme, domain.ThemeAccent{Mode: domain.ThemeAccentSlot, Slot: 2})
176 require.False(t, off.Accent.Known)
177 require.Equal(t, neutralStyles(theme), off.Styles)
178
179 insufficient := rampTheme(false)
180 insufficient.Foreground = insufficient.Background
181 insufficient.Palette[2] = renderer.RGB{R: 0x7d, G: 0xb5, B: 0xb5}
182 insufficient.PaletteKnown = 1 << 2
183 neutral := Resolve(insufficient, domain.ThemeAccent{Mode: domain.ThemeAccentSlot, Slot: 2})
184 require.Equal(t, neutralStyles(insufficient), neutral.Styles)
185}
186
187func rampTheme(light bool) Theme {
188 if light {
189 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}
190 }
191 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}
192}