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, TrueColor: 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.TrueColor = false
159 theme.Palette[2] = renderer.RGB{R: 0x7d, G: 0xb5, B: 0xb5}
160 theme.PaletteKnown = 1 << 2
161 indexed := Resolve(theme, domain.ThemeAccent{Mode: domain.ThemeAccentSlot, Slot: 2})
162 require.False(t, indexed.Accent.IndexedOnly)
163 for name, style := range map[string]renderer.Style{
164 "active tab": indexed.Styles.TabActive,
165 "active title": indexed.Styles.TabActiveTitle,
166 "picker selection": indexed.Styles.PickerSelection,
167 "search selection": indexed.Styles.SearchSelection,
168 } {
169 t.Run(name, func(t *testing.T) {
170 require.True(t, style.HasBackgroundRGB)
171 require.Equal(t, indexed.Styles.SurfaceActive.BackgroundRGB, style.BackgroundRGB)
172 })
173 }
174
175 theme.TrueColor = true
176 theme.UsePalette = false
177 off := Resolve(theme, domain.ThemeAccent{Mode: domain.ThemeAccentSlot, Slot: 2})
178 require.False(t, off.Accent.Known)
179 require.Equal(t, neutralStyles(theme), off.Styles)
180
181 insufficient := rampTheme(false)
182 insufficient.Foreground = insufficient.Background
183 insufficient.Palette[2] = renderer.RGB{R: 0x7d, G: 0xb5, B: 0xb5}
184 insufficient.PaletteKnown = 1 << 2
185 neutral := Resolve(insufficient, domain.ThemeAccent{Mode: domain.ThemeAccentSlot, Slot: 2})
186 require.Equal(t, neutralStyles(insufficient), neutral.Styles)
187}
188
189func rampTheme(light bool) Theme {
190 if light {
191 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}
192 }
193 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}
194}