1use anstyle::{Color, Effects, RgbColor, Style};
2use oxicode_vtui_compat::constants::{defaults, ui};
3
4use crate::theme::color_math::{balance_text_luminance, ensure_contrast, lighten, mix};
5
6pub const DEFAULT_THEME_ID: &str = defaults::DEFAULT_THEME;
8
9const DEFAULT_MIN_CONTRAST: f32 = ui::THEME_MIN_CONTRAST_RATIO;
10
11#[derive(Clone, Debug)]
13pub struct ColorAccessibilityConfig {
14 pub minimum_contrast: f32,
15 pub bold_is_bright: bool,
16 pub safe_colors_only: bool,
17}
18
19impl Default for ColorAccessibilityConfig {
20 fn default() -> Self {
21 Self {
22 minimum_contrast: DEFAULT_MIN_CONTRAST,
23 bold_is_bright: false,
24 safe_colors_only: false,
25 }
26 }
27}
28
29#[derive(Clone, Debug)]
31pub struct ThemePalette {
32 pub(crate) primary_accent: RgbColor,
33 pub(crate) background: RgbColor,
34 pub(crate) foreground: RgbColor,
35 pub(crate) secondary_accent: RgbColor,
36 pub(crate) alert: RgbColor,
37 pub(crate) logo_accent: RgbColor,
38}
39
40#[derive(Clone, Debug)]
46pub(crate) struct ColorContext {
47 pub background: RgbColor,
48 pub min_contrast: f32,
49 pub fallback_light: RgbColor,
50}
51
52impl ColorContext {
53 fn new(background: RgbColor, min_contrast: f32) -> Self {
54 Self {
55 background,
56 min_contrast,
57 fallback_light: RgbColor(
58 (ui::THEME_COLOR_WHITE_RED * 255.0) as u8,
59 (ui::THEME_COLOR_WHITE_GREEN * 255.0) as u8,
60 (ui::THEME_COLOR_WHITE_BLUE * 255.0) as u8,
61 ),
62 }
63 }
64
65 fn guaranteed_text_color(&self, candidate: RgbColor, fallbacks: &[RgbColor]) -> RgbColor {
68 let color = ensure_contrast(candidate, self.background, self.min_contrast, fallbacks);
69 balance_text_luminance(color, self.background, self.min_contrast)
70 }
71
72 fn guaranteed_accent_color(&self, candidate: RgbColor, fallbacks: &[RgbColor]) -> RgbColor {
75 ensure_contrast(candidate, self.background, self.min_contrast, fallbacks)
76 }
77
78 fn compute_text_color(&self, foreground: RgbColor, secondary: RgbColor) -> RgbColor {
80 self.guaranteed_text_color(
81 foreground,
82 &[
83 lighten(foreground, ui::THEME_FOREGROUND_LIGHTEN_RATIO),
84 lighten(secondary, ui::THEME_SECONDARY_LIGHTEN_RATIO),
85 self.fallback_light,
86 ],
87 )
88 }
89
90 fn compute_info_color(&self, secondary: RgbColor, text_color: RgbColor) -> RgbColor {
92 self.guaranteed_text_color(
93 secondary,
94 &[
95 lighten(secondary, ui::THEME_SECONDARY_LIGHTEN_RATIO),
96 text_color,
97 self.fallback_light,
98 ],
99 )
100 }
101
102 fn compute_tool_color(&self, text_color: RgbColor) -> RgbColor {
104 self.guaranteed_accent_color(
105 lighten(text_color, ui::THEME_MIX_RATIO),
106 &[
107 lighten(
108 lighten(text_color, ui::THEME_MIX_RATIO),
109 ui::THEME_TOOL_BODY_LIGHTEN_RATIO,
110 ),
111 text_color,
112 self.fallback_light,
113 ],
114 )
115 }
116
117 fn compute_tool_body_color(&self, text_color: RgbColor) -> RgbColor {
119 let candidate = mix(
120 lighten(text_color, ui::THEME_MIX_RATIO),
121 text_color,
122 ui::THEME_TOOL_BODY_MIX_RATIO,
123 );
124 self.guaranteed_accent_color(
125 candidate,
126 &[
127 lighten(
128 lighten(text_color, ui::THEME_MIX_RATIO),
129 ui::THEME_TOOL_BODY_LIGHTEN_RATIO,
130 ),
131 text_color,
132 self.fallback_light,
133 ],
134 )
135 }
136
137 fn compute_pty_output_color(
140 &self,
141 tool_body_color: RgbColor,
142 text_color: RgbColor,
143 ) -> RgbColor {
144 let candidate = mix(
145 tool_body_color,
146 self.background,
147 ui::THEME_PTY_OUTPUT_MIX_RATIO,
148 );
149 self.guaranteed_text_color(candidate, &[tool_body_color, text_color])
150 }
151
152 fn compute_response_color(&self, text_color: RgbColor) -> RgbColor {
154 self.guaranteed_text_color(
155 text_color,
156 &[
157 lighten(text_color, ui::THEME_RESPONSE_COLOR_LIGHTEN_RATIO),
158 self.fallback_light,
159 ],
160 )
161 }
162
163 fn compute_reasoning_color(&self, text_color: RgbColor) -> RgbColor {
165 self.guaranteed_text_color(
166 lighten(text_color, 0.25),
167 &[lighten(text_color, 0.15), text_color, self.fallback_light],
168 )
169 }
170
171 fn compute_user_color(
173 &self,
174 secondary: RgbColor,
175 info_color: RgbColor,
176 text_color: RgbColor,
177 ) -> RgbColor {
178 self.guaranteed_text_color(
179 lighten(secondary, ui::THEME_USER_COLOR_LIGHTEN_RATIO),
180 &[
181 lighten(secondary, ui::THEME_SECONDARY_USER_COLOR_LIGHTEN_RATIO),
182 info_color,
183 text_color,
184 ],
185 )
186 }
187
188 fn compute_alert_color(&self, alert: RgbColor, text_color: RgbColor) -> RgbColor {
190 self.guaranteed_text_color(
191 alert,
192 &[
193 lighten(alert, ui::THEME_LUMINANCE_LIGHTEN_RATIO),
194 self.fallback_light,
195 text_color,
196 ],
197 )
198 }
199
200 fn compute_primary_color(&self, primary: RgbColor, text_color: RgbColor) -> RgbColor {
202 self.guaranteed_text_color(
203 ensure_contrast(primary, self.background, self.min_contrast, &[text_color]),
204 &[text_color],
205 )
206 }
207
208 fn compute_secondary_color(
210 &self,
211 secondary: RgbColor,
212 info_color: RgbColor,
213 text_color: RgbColor,
214 ) -> RgbColor {
215 self.guaranteed_text_color(
216 ensure_contrast(
217 secondary,
218 self.background,
219 self.min_contrast,
220 &[info_color, text_color],
221 ),
222 &[info_color, text_color],
223 )
224 }
225
226 fn compute_logo_color(
228 &self,
229 logo_accent: RgbColor,
230 secondary_color: RgbColor,
231 text_color: RgbColor,
232 ) -> RgbColor {
233 self.guaranteed_text_color(
234 ensure_contrast(
235 logo_accent,
236 self.background,
237 self.min_contrast,
238 &[secondary_color, text_color],
239 ),
240 &[secondary_color, text_color],
241 )
242 }
243
244 fn compute_status_color(
246 &self,
247 primary_color: RgbColor,
248 info_color: RgbColor,
249 text_color: RgbColor,
250 ) -> RgbColor {
251 self.guaranteed_accent_color(
252 lighten(primary_color, ui::THEME_PRIMARY_STATUS_LIGHTEN_RATIO),
253 &[
254 lighten(
255 primary_color,
256 ui::THEME_PRIMARY_STATUS_SECONDARY_LIGHTEN_RATIO,
257 ),
258 info_color,
259 text_color,
260 ],
261 )
262 }
263
264 fn compute_mcp_color(&self, logo_color: RgbColor, info_color: RgbColor) -> RgbColor {
266 self.guaranteed_accent_color(
267 lighten(logo_color, ui::THEME_SECONDARY_LIGHTEN_RATIO),
268 &[
269 lighten(logo_color, ui::THEME_LOGO_ACCENT_BANNER_LIGHTEN_RATIO),
270 info_color,
271 self.fallback_light,
272 ],
273 )
274 }
275}
276
277impl ThemePalette {
278 fn style_from(color: RgbColor, bold: bool, bold_is_bright: bool) -> Style {
279 let mut style = Style::new().fg_color(Some(Color::Rgb(color)));
280 if bold && !bold_is_bright {
281 style = style.bold();
282 }
283 style
284 }
285
286 pub(crate) fn build_styles_with_accessibility(
287 &self,
288 accessibility: &ColorAccessibilityConfig,
289 ) -> ThemeStyles {
290 let ctx = ColorContext::new(self.background, accessibility.minimum_contrast);
291 let bold_is_bright = accessibility.bold_is_bright;
292
293 let text = ctx.compute_text_color(self.foreground, self.secondary_accent);
294 let info = ctx.compute_info_color(self.secondary_accent, text);
295 let tool_body = ctx.compute_tool_body_color(text);
296 let pty = ctx.compute_pty_output_color(tool_body, text);
297 let primary = ctx.compute_primary_color(self.primary_accent, text);
298 let secondary = ctx.compute_secondary_color(self.secondary_accent, info, text);
299 let logo = ctx.compute_logo_color(self.logo_accent, secondary, text);
300
301 ThemeStyles {
302 info: Self::style_from(info, true, bold_is_bright),
303 error: Self::style_from(
304 ctx.compute_alert_color(self.alert, text),
305 true,
306 bold_is_bright,
307 ),
308 output: Self::style_from(text, false, bold_is_bright),
309 response: Self::style_from(ctx.compute_response_color(text), false, bold_is_bright),
310 reasoning: Self::style_from(ctx.compute_reasoning_color(text), false, bold_is_bright)
311 .effects(Effects::DIMMED | Effects::ITALIC),
312 tool: Style::new().fg_color(Some(Color::Rgb(ctx.compute_tool_color(text)))),
313 tool_detail: Style::new().fg_color(Some(Color::Rgb(tool_body))),
314 tool_output: Style::new(),
315 pty_output: Style::new().fg_color(Some(Color::Rgb(pty))),
316 status: Self::style_from(
317 ctx.compute_status_color(primary, info, text),
318 true,
319 bold_is_bright,
320 ),
321 mcp: Self::style_from(ctx.compute_mcp_color(logo, info), true, bold_is_bright),
322 user: Self::style_from(
323 ctx.compute_user_color(self.secondary_accent, info, text),
324 false,
325 bold_is_bright,
326 ),
327 primary: Self::style_from(primary, false, bold_is_bright),
328 secondary: Self::style_from(secondary, false, bold_is_bright),
329 background: Color::Rgb(self.background),
330 foreground: Color::Rgb(text),
331 }
332 }
333}
334
335#[derive(Clone, Debug)]
337pub struct ThemeStyles {
338 pub info: Style,
339 pub error: Style,
340 pub output: Style,
341 pub response: Style,
342 pub reasoning: Style,
343 pub tool: Style,
344 pub tool_detail: Style,
345 pub tool_output: Style,
346 pub pty_output: Style,
347 pub status: Style,
348 pub mcp: Style,
349 pub user: Style,
350 pub primary: Style,
351 pub secondary: Style,
352 pub background: Color,
353 pub foreground: Color,
354}
355
356#[derive(Clone, Debug)]
357pub struct ThemeDefinition {
358 pub(crate) id: &'static str,
359 pub(crate) label: &'static str,
360 pub(crate) palette: ThemePalette,
361}
362
363#[derive(Clone, Debug, PartialEq, Eq)]
365pub struct ThemeSuite {
366 pub(crate) id: &'static str,
367 pub(crate) label: &'static str,
368 pub(crate) theme_ids: Vec<&'static str>,
369}
370
371#[derive(Debug, Clone)]
373pub struct ThemeValidationResult {
374 pub(crate) is_valid: bool,
375 pub warnings: Vec<String>,
376 pub(crate) errors: Vec<String>,
377}