1package theme
2
3import (
4 renderer "github.com/bnema/vev-vt"
5 "github.com/bnema/vev/internal/domain"
6)
7
8const neutralAccentBlend = 0.25
9
10// ResolvedTheme is the immutable, complete chrome style snapshot produced from
11// a terminal theme and typed accent policy.
12type ResolvedTheme struct {
13 Theme Theme
14 Accent Accent
15 Ramp Ramp
16 Styles Styles
17}
18
19// Resolve is the single entry point for terminal-owned accent inference and
20// semantic chrome construction.
21func Resolve(t Theme, policy domain.ThemeAccent) ResolvedTheme {
22 accent := ResolveAccent(t, policy)
23 ramp := BuildRamp(t, accent)
24 styles := stylesFromRamp(t, accent, ramp)
25 return ResolvedTheme{Theme: t, Accent: accent, Ramp: ramp, Styles: styles}
26}
27
28func stylesFromRamp(t Theme, accent Accent, ramp Ramp) Styles {
29 if !accent.Known || accent.IndexedOnly || !usable(t) || !ramp.rgb {
30 styles := neutralStyles(t)
31 if accent.IndexedOnly {
32 styles = indexedStyles(styles, accent.Slot)
33 }
34 return styles
35 }
36
37 inactiveTitle := secondarySurface(ramp.SurfaceInactive)
38 activeTitle := secondarySurface(ramp.SurfaceActive)
39 overlayDescription := overlaySecondaryText(t)
40 styles := Styles{
41 SurfaceBar: ramp.SurfaceBar,
42 SurfaceInactive: ramp.SurfaceInactive,
43 SurfaceRecent: ramp.SurfaceRecent,
44 SurfaceActive: ramp.SurfaceActive,
45 BorderMuted: ramp.BorderMuted,
46 BorderActive: ramp.BorderActive,
47 BorderWarn: ramp.BorderWarn,
48 NeutralBorder: neutralBorder(t),
49
50 TabInactive: ramp.SurfaceInactive,
51 TabInactiveTitle: inactiveTitle,
52 TabActive: EmphasisStyle(ramp.SurfaceActive, t),
53 TabActiveTitle: activeTitle,
54 MRURecent: ramp.SurfaceRecent,
55
56 PickerBase: renderer.DefaultStyle(),
57 PickerSelection: EmphasisStyle(ramp.SurfaceActive, t),
58 HintKey: EmphasisStyle(ramp.SurfaceActive, t),
59 PickerDescription: overlayDescription,
60 // Separators are secondary text on the terminal background, not
61 // non-text borders; they therefore require the normal 4.5:1 contrast.
62 PickerSeparator: overlayDescription,
63 PromptBase: renderer.DefaultStyle(),
64 CopyStatus: ramp.SurfaceBar,
65 SearchSelection: EmphasisStyle(ramp.SurfaceActive, t),
66 }
67 return withMRUStyles(withLegacyAliases(styles, t), ramp)
68}
69
70func overlaySecondaryText(t Theme) renderer.Style {
71 if !usable(t) {
72 return renderer.DefaultStyle()
73 }
74 foreground, ok := secondaryText(t.Foreground, t.Background)
75 if !ok {
76 return foregroundStyle(t.Foreground)
77 }
78 return foregroundStyle(foreground)
79}
80
81func secondarySurface(base renderer.Style) renderer.Style {
82 if !base.HasForegroundRGB || !base.HasBackgroundRGB {
83 return base
84 }
85 foreground, ok := secondaryText(base.ForegroundRGB, base.BackgroundRGB)
86 if !ok {
87 return base
88 }
89 base.ForegroundRGB = foreground
90 return base
91}
92
93func indexedStyles(styles Styles, slot uint8) Styles {
94 styles.BorderMuted = indexedForeground(styles.BorderMuted, slot)
95 styles.BorderActive = indexedForeground(styles.BorderActive, slot)
96 styles.BorderWarn = indexedForeground(styles.BorderWarn, slot)
97 styles.TabInactiveTitle = indexedForeground(styles.TabInactiveTitle, slot)
98 styles.TabActiveTitle = indexedForeground(styles.TabActiveTitle, slot)
99 styles.PickerDescription = indexedForeground(styles.PickerDescription, slot)
100 styles.PickerSeparator = indexedForeground(styles.PickerSeparator, slot)
101
102 // Compatibility aliases historically received the scheme-aware indexed
103 // blue fallback too. Keep the strict explicit index on each foreground
104 // decoration without changing any neutral surface background.
105 styles.Border = styles.BorderMuted
106 styles.PaletteDesc = indexedForeground(styles.PaletteDesc, slot)
107 styles.TabTitle = styles.TabInactiveTitle
108 styles.TabTitleActive = styles.TabActiveTitle
109 styles.PickerSelectionMuted = indexedForeground(styles.PickerSelectionMuted, slot)
110 return styles
111}
112
113func indexedForeground(style renderer.Style, slot uint8) renderer.Style {
114 style.HasForegroundRGB = false
115 style.Foreground = int(slot)
116 style.Inverse = false
117 return style
118}
119
120// neutralStyles deliberately preserves the pre-accent neutral hierarchy. It
121// is used for palette-off, forced themes, incomplete defaults, and RGB-less
122// indexed fallbacks; it never performs a palette slot lookup.
123func neutralStyles(t Theme) Styles {
124 status := renderer.DefaultStyle()
125 accent := inverseStyle()
126 border := renderer.DefaultStyle()
127 overlayDescription := renderer.DefaultStyle()
128 if usable(t) {
129 status = rgbSurface(t.Foreground, Blend(t.Background, t.Foreground, 0.12))
130 accent = rgbSurface(t.Foreground, Blend(t.Background, t.Foreground, neutralAccentBlend))
131 border = neutralBorder(t)
132 overlayDescription = overlaySecondaryText(t)
133 }
134 styles := Styles{
135 SurfaceBar: status,
136 SurfaceInactive: status,
137 SurfaceRecent: status,
138 SurfaceActive: accent,
139 BorderMuted: border,
140 BorderActive: border,
141 BorderWarn: border,
142 NeutralBorder: border,
143
144 TabInactive: EmphasisStyle(status, t),
145 TabInactiveTitle: MutedVariantStyle(status, t),
146 TabActive: EmphasisStyle(accent, t),
147 TabActiveTitle: MutedVariantStyle(accent, t),
148 MRURecent: status,
149
150 PickerBase: renderer.DefaultStyle(),
151 PickerSelection: EmphasisStyle(accent, t),
152 HintKey: EmphasisStyle(accent, t),
153 PickerDescription: overlayDescription,
154 PickerSeparator: overlayDescription,
155 PromptBase: renderer.DefaultStyle(),
156 CopyStatus: accent,
157 SearchSelection: accent,
158 }
159 styles = withLegacyAliases(styles, t)
160 // PaletteDesc is a Phase 3 compatibility alias. It retains its legacy
161 // foreground-only bytes; rendered palette descriptions use PickerDescription.
162 styles.PaletteDesc = legacyMutedText(t)
163 styles = withMRUStyles(styles, Ramp{SurfaceBar: styles.SurfaceBar, SurfaceRecent: styles.SurfaceRecent})
164 if usable(t) {
165 styles.TabActive = EmphasisStyle(rgbSurface(t.Background, t.Foreground), t)
166 styles.TabNameActive = styles.TabActive
167 // Keep the neutral history ordered visually, even without a palette
168 // accent: recent entries fade into the bar as their rank increases.
169 for count := 1; count <= len(styles.mruStyles); count++ {
170 for index := range count {
171 amount := float64(index+1) / float64(count)
172 background := Blend(t.Foreground, status.BackgroundRGB, amount)
173 foreground, ok := primaryText(t, background)
174 if !ok {
175 foreground, _ = primaryText(Theme{Foreground: renderer.RGB{R: 255, G: 255, B: 255}}, background)
176 }
177 styles.mruStyles[count-1][index] = rgbSurface(foreground, background)
178 }
179 }
180 }
181 return styles
182}
183
184func withMRUStyles(styles Styles, ramp Ramp) Styles {
185 for count := 1; count <= len(styles.mruStyles); count++ {
186 for index := 0; index < count; index++ {
187 styles.mruStyles[count-1][index] = MRUStyle(ramp, index, count)
188 }
189 }
190 return styles
191}
192
193// neutralBorder is the existing non-accent structural border input for pane
194// chrome. It intentionally does not consume a resolved accent and is called
195// only while resolving an immutable style snapshot.
196func neutralBorder(t Theme) renderer.Style {
197 if !usable(t) {
198 return renderer.DefaultStyle()
199 }
200 return foregroundStyle(Blend(t.Foreground, t.Background, 0.40))
201}
202
203func legacyMutedText(t Theme) renderer.Style {
204 if !usable(t) {
205 return renderer.DefaultStyle()
206 }
207 return foregroundStyle(Blend(t.Foreground, t.Background, 0.45))
208}
209
210func withLegacyAliases(styles Styles, t Theme) Styles {
211 styles.StatusBar = styles.SurfaceBar
212 styles.Accent = styles.SurfaceActive
213 styles.Border = styles.BorderMuted
214 styles.Selection = styles.SurfaceActive
215 styles.PaletteDesc = styles.PickerDescription
216 styles.TabName = styles.TabInactive
217 styles.TabNameActive = styles.TabActive
218 styles.TabTitle = styles.TabInactiveTitle
219 styles.TabTitleActive = styles.TabActiveTitle
220 // These aliases retain their pre-semantic-role contracts for render paths
221 // that have not migrated yet.
222 styles.PickerName = EmphasisStyle(renderer.DefaultStyle(), t)
223 styles.PickerSelectionName = EmphasisStyle(styles.Selection, t)
224 styles.PickerSelectionMuted = MutedVariantStyle(styles.Selection, t)
225 return styles
226}