1package theme
2
3import (
4 "math"
5
6 renderer "github.com/bnema/vev-vt"
7)
8
9const (
10 normalTextContrast = 4.5
11 borderContrast = 3.0
12
13 // warnHueDegrees is the fixed OKLCh hue target for the Warn border role.
14 // Reference amber hues under this package's own rgbToOKLab: conventional
15 // "amber" #FFBF00 sits at ~84 degrees, Tailwind's amber-500 (#F59E0B) at
16 // ~70 degrees, CSS "orange" at ~71 degrees. 75 degrees sits centrally in
17 // that band, so a warn border reads as amber/gold regardless of which
18 // hue the user's single accent happens to occupy (blue ~256, red ~29,
19 // green ~145 under the same conversion).
20 warnHueDegrees = 75.0
21)
22
23// Ramp is the contrast-safe set of semantic terminal chrome colors.
24type Ramp struct {
25 SurfaceBar renderer.Style
26 SurfaceInactive renderer.Style
27 SurfaceRecent renderer.Style
28 SurfaceActive renderer.Style
29 BorderMuted renderer.Style
30 BorderActive renderer.Style
31 BorderWarn renderer.Style
32
33 background renderer.RGB
34 foreground renderer.RGB
35 rgb bool
36}
37
38// BuildRamp derives every RGB surface from the terminal background and one
39// resolved accent. It deliberately never uses indexed colors for backgrounds.
40func BuildRamp(t Theme, accent Accent) Ramp {
41 if !accent.Known || accent.IndexedOnly || !usable(t) {
42 return neutralRamp(t)
43 }
44
45 bar, ok := surfaceAtOrBelow(t, accent.RGB, 8)
46 if !ok {
47 return neutralRamp(t)
48 }
49 inactive, ok := surfaceAtOrBelow(t, accent.RGB, 14)
50 if !ok {
51 return neutralRamp(t)
52 }
53 recent, ok := surfaceAtOrBelow(t, accent.RGB, 22)
54 if !ok {
55 return neutralRamp(t)
56 }
57 active, ok := surfaceAtOrBelow(t, accent.RGB, 100)
58 if !ok {
59 return neutralRamp(t)
60 }
61
62 return Ramp{
63 SurfaceBar: bar,
64 SurfaceInactive: inactive,
65 SurfaceRecent: recent,
66 SurfaceActive: active,
67 BorderMuted: mutedBorder(t, accent.RGB, bar.BackgroundRGB),
68 BorderActive: activeBorder(t, accent.RGB, bar.BackgroundRGB),
69 BorderWarn: warnBorder(t, accent.RGB, bar.BackgroundRGB),
70 background: t.Background,
71 foreground: t.Foreground,
72 rgb: true,
73 }
74}
75
76// surfaceAtOrBelow searches from the requested intensity to neutral. The
77// integer sequence is intentional: it makes contrast adaptation stable.
78func surfaceAtOrBelow(t Theme, accent renderer.RGB, target int) (renderer.Style, bool) {
79 for weight := target; weight >= 0; weight-- {
80 background := okLabLerp(t.Background, accent, float64(weight)/100)
81 foreground, ok := primaryText(t, background)
82 if !ok {
83 continue
84 }
85 if _, ok := secondaryText(foreground, background); !ok {
86 continue
87 }
88 return rgbSurface(foreground, background), true
89 }
90 return renderer.Style{}, false
91}
92
93func rgbSurface(foreground, background renderer.RGB) renderer.Style {
94 style := renderer.DefaultStyle()
95 style.HasForegroundRGB = true
96 style.ForegroundRGB = foreground
97 style.HasBackgroundRGB = true
98 style.BackgroundRGB = background
99 return style
100}
101
102// primaryText chooses only the reported default foreground or background.
103func primaryText(t Theme, surface renderer.RGB) (renderer.RGB, bool) {
104 best := t.Foreground
105 bestContrast := ContrastRatio(t.Foreground, surface)
106 backgroundContrast := ContrastRatio(t.Background, surface)
107 if backgroundContrast > bestContrast {
108 best, bestContrast = t.Background, backgroundContrast
109 }
110 return best, bestContrast >= normalTextContrast
111}
112
113// secondaryText mutes toward the actual surface while retaining normal-text
114// contrast. Searching down from 40 selects the greatest permitted mute.
115func secondaryText(primary, surface renderer.RGB) (renderer.RGB, bool) {
116 for weight := 40; weight >= 0; weight-- {
117 candidate := okLabLerp(primary, surface, float64(weight)/100)
118 if ContrastRatio(candidate, surface) >= normalTextContrast {
119 return candidate, true
120 }
121 }
122 return renderer.RGB{}, false
123}
124
125// mutedBorder starts at 60 percent and only moves toward the accent, choosing
126// the lowest weight that separates from its adjacent surface.
127func mutedBorder(t Theme, accent, adjacent renderer.RGB) renderer.Style {
128 for weight := 60; weight <= 100; weight++ {
129 candidate := okLabLerp(t.Background, accent, float64(weight)/100)
130 if ContrastRatio(candidate, adjacent) >= borderContrast {
131 return foregroundStyle(candidate)
132 }
133 }
134 return neutralBorder(t)
135}
136
137// activeBorder starts at the accent endpoint and reduces only when needed.
138func activeBorder(t Theme, accent, adjacent renderer.RGB) renderer.Style {
139 for weight := 100; weight >= 0; weight-- {
140 candidate := okLabLerp(t.Background, accent, float64(weight)/100)
141 if ContrastRatio(candidate, adjacent) >= borderContrast {
142 return foregroundStyle(candidate)
143 }
144 }
145 return neutralBorder(t)
146}
147
148// warnBorder searches the same accent-derived weight scale activeBorder
149// uses, but replaces each candidate's hue with the fixed amber target before
150// checking contrast: the result keeps the accent's lightness/chroma band
151// (so it belongs to the ramp) while always reading as amber, regardless of
152// the user's accent hue.
153func warnBorder(t Theme, accent, adjacent renderer.RGB) renderer.Style {
154 for weight := 100; weight >= 0; weight-- {
155 candidate := okLabLerp(t.Background, accent, float64(weight)/100)
156 amber := shiftHue(candidate, warnHueDegrees)
157 if ContrastRatio(amber, adjacent) >= borderContrast {
158 return foregroundStyle(amber)
159 }
160 }
161 return neutralBorder(t)
162}
163
164func foregroundStyle(color renderer.RGB) renderer.Style {
165 style := renderer.DefaultStyle()
166 style.HasForegroundRGB = true
167 style.ForegroundRGB = color
168 return style
169}
170
171func neutralRamp(t Theme) Ramp {
172 styles := neutralStyles(t)
173 return Ramp{
174 SurfaceBar: styles.SurfaceBar,
175 SurfaceInactive: styles.SurfaceInactive,
176 SurfaceRecent: styles.SurfaceRecent,
177 SurfaceActive: styles.SurfaceActive,
178 BorderMuted: styles.BorderMuted,
179 BorderActive: styles.BorderActive,
180 BorderWarn: styles.BorderWarn,
181 }
182}
183
184// mruWeight is the share of the way from the bar to the active surface for a
185// history entry. The newest entry always sits halfway; the remaining entries
186// split that lower half into equal steps, so the oldest stays one step above
187// the bar: 1 entry → 50; 2 → 50, 25; 3 → 50, 33, 17.
188func mruWeight(index, count int) float64 {
189 return 0.5 * (1 - float64(index)/float64(count))
190}
191
192// mruMaxTextDim is how far the oldest history entry's text fades toward its
193// background: it keeps 70% of its strength.
194const mruMaxTextDim = 0.3
195
196// mruTextDim is the share of the way from the text color to the background for
197// a history entry: the newest keeps full text, the oldest fades by
198// mruMaxTextDim, linearly in between: 3 entries → 0, 0.15, 0.3.
199func mruTextDim(index, count int) float64 {
200 if count <= 1 {
201 return 0
202 }
203 return mruMaxTextDim * float64(index) / float64(count-1)
204}
205
206// dimText fades text toward its surface by up to weight, never below
207// normal-text contrast unless the undimmed text was already below it.
208func dimText(text, surface renderer.RGB, weight float64) renderer.RGB {
209 for step := int(math.Round(weight * 100)); step > 0; step-- {
210 candidate := okLabLerp(text, surface, float64(step)/100)
211 if ContrastRatio(candidate, surface) >= normalTextContrast {
212 return candidate
213 }
214 }
215 return text
216}
217
218// MRUStyle places history entries by mruWeight between the bar and the active
219// surface. The background is never moved for contrast; the text picks the
220// better of the theme foreground and background, then fades by mruTextDim
221// within normal-text contrast.
222func MRUStyle(ramp Ramp, index, count int) renderer.Style {
223 if !ramp.rgb || count <= 0 {
224 return ramp.SurfaceRecent
225 }
226 index = max(0, min(index, count-1))
227 weight := mruWeight(index, count)
228 background := okLabLerp(ramp.SurfaceBar.BackgroundRGB, ramp.SurfaceActive.BackgroundRGB, weight)
229 foreground, _ := primaryText(Theme{Foreground: ramp.foreground, Background: ramp.background}, background)
230 return rgbSurface(dimText(foreground, background, mruTextDim(index, count)), background)
231}