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 // On the full-strength neutral surface an indexed title color cannot be
99 // kept readable, so the active title keeps its RGB secondary text.
100 if !styles.TabActiveTitle.HasBackgroundRGB {
101 styles.TabActiveTitle = indexedForeground(styles.TabActiveTitle, slot)
102 }
103 styles.PickerDescription = indexedForeground(styles.PickerDescription, slot)
104 styles.PickerSeparator = indexedForeground(styles.PickerSeparator, slot)
105
106 // Compatibility aliases historically received the scheme-aware indexed
107 // blue fallback too. Keep the strict explicit index on each foreground
108 // decoration without changing any neutral surface background.
109 styles.Border = styles.BorderMuted
110 styles.PaletteDesc = indexedForeground(styles.PaletteDesc, slot)
111 styles.TabTitle = styles.TabInactiveTitle
112 styles.TabTitleActive = styles.TabActiveTitle
113 styles.PickerSelectionMuted = indexedForeground(styles.PickerSelectionMuted, slot)
114 return styles
115}
116
117func indexedForeground(style renderer.Style, slot uint8) renderer.Style {
118 style.HasForegroundRGB = false
119 style.Foreground = int(slot)
120 style.Inverse = false
121 return style
122}
123
124// neutralStyles uses full-strength session surfaces and softer overlay selections.
125// It is used for palette-off, forced themes, incomplete defaults, and RGB-less
126// indexed fallbacks; it never performs a palette slot lookup.
127func neutralStyles(t Theme) Styles {
128 status := renderer.DefaultStyle()
129 accent := inverseStyle()
130 border := renderer.DefaultStyle()
131 overlayDescription := renderer.DefaultStyle()
132 if usable(t) {
133 status = rgbSurface(t.Foreground, Blend(t.Background, t.Foreground, 0.12))
134 accent = rgbSurface(t.Foreground, Blend(t.Background, t.Foreground, neutralAccentBlend))
135 border = neutralBorder(t)
136 overlayDescription = overlaySecondaryText(t)
137 }
138 styles := Styles{
139 SurfaceBar: status,
140 SurfaceInactive: status,
141 SurfaceRecent: status,
142 SurfaceActive: accent,
143 BorderMuted: border,
144 BorderActive: border,
145 BorderWarn: border,
146 NeutralBorder: border,
147
148 TabInactive: EmphasisStyle(status, t),
149 TabInactiveTitle: MutedVariantStyle(status, t),
150 TabActive: EmphasisStyle(accent, t),
151 TabActiveTitle: MutedVariantStyle(accent, t),
152 MRURecent: status,
153
154 PickerBase: renderer.DefaultStyle(),
155 PickerSelection: EmphasisStyle(accent, t),
156 HintKey: EmphasisStyle(accent, t),
157 PickerDescription: overlayDescription,
158 PickerSeparator: overlayDescription,
159 PromptBase: renderer.DefaultStyle(),
160 CopyStatus: accent,
161 SearchSelection: accent,
162 }
163 styles = withLegacyAliases(styles, t)
164 // PaletteDesc is a Phase 3 compatibility alias. It retains its legacy
165 // foreground-only bytes; rendered palette descriptions use PickerDescription.
166 styles.PaletteDesc = legacyMutedText(t)
167 styles = withMRUStyles(styles, Ramp{SurfaceBar: styles.SurfaceBar, SurfaceRecent: styles.SurfaceRecent})
168 if usable(t) {
169 // Sessions share the full-strength neutral endpoint with active tabs;
170 // overlay selections retain their softer neutral surface.
171 styles.SurfaceActive = rgbSurface(t.Background, t.Foreground)
172 styles.Accent = styles.SurfaceActive
173 styles.TabActive = EmphasisStyle(styles.SurfaceActive, t)
174 styles.TabNameActive = styles.TabActive
175 styles.TabActiveTitle = MutedVariantStyle(styles.SurfaceActive, t)
176 styles.TabTitleActive = styles.TabActiveTitle
177 // Without a palette accent the history uses the same mruWeight
178 // placement as MRUStyle, between the active tab and the bar.
179 for count := 1; count <= len(styles.mruStyles); count++ {
180 for index := range count {
181 amount := 1 - mruWeight(index, count)
182 background := Blend(t.Foreground, status.BackgroundRGB, amount)
183 foreground, ok := primaryText(t, background)
184 if !ok {
185 foreground, _ = primaryText(Theme{Foreground: renderer.RGB{R: 255, G: 255, B: 255}}, background)
186 }
187 styles.mruStyles[count-1][index] = rgbSurface(foreground, background)
188 }
189 }
190 }
191 return styles
192}
193
194func withMRUStyles(styles Styles, ramp Ramp) Styles {
195 for count := 1; count <= len(styles.mruStyles); count++ {
196 for index := 0; index < count; index++ {
197 styles.mruStyles[count-1][index] = MRUStyle(ramp, index, count)
198 }
199 }
200 return styles
201}
202
203// neutralBorder is the existing non-accent structural border input for pane
204// chrome. It intentionally does not consume a resolved accent and is called
205// only while resolving an immutable style snapshot.
206func neutralBorder(t Theme) renderer.Style {
207 if !usable(t) {
208 return renderer.DefaultStyle()
209 }
210 return foregroundStyle(Blend(t.Foreground, t.Background, 0.40))
211}
212
213func legacyMutedText(t Theme) renderer.Style {
214 if !usable(t) {
215 return renderer.DefaultStyle()
216 }
217 return foregroundStyle(Blend(t.Foreground, t.Background, 0.45))
218}
219
220func withLegacyAliases(styles Styles, t Theme) Styles {
221 styles.StatusBar = styles.SurfaceBar
222 styles.Accent = styles.SurfaceActive
223 styles.Border = styles.BorderMuted
224 styles.Selection = styles.SurfaceActive
225 styles.PaletteDesc = styles.PickerDescription
226 styles.TabName = styles.TabInactive
227 styles.TabNameActive = styles.TabActive
228 styles.TabTitle = styles.TabInactiveTitle
229 styles.TabTitleActive = styles.TabActiveTitle
230 // These aliases retain their pre-semantic-role contracts for render paths
231 // that have not migrated yet.
232 styles.PickerName = EmphasisStyle(renderer.DefaultStyle(), t)
233 styles.PickerSelectionName = EmphasisStyle(styles.Selection, t)
234 styles.PickerSelectionMuted = MutedVariantStyle(styles.Selection, t)
235 return styles
236}