1pub mod color_mode;
19pub mod palette;
20pub mod presets;
21
22pub use color_mode::{
23 EffectiveColorMode, apply_mode, detect_unicode_capable, map_color, resolve_color_mode,
24};
25pub use palette::{ExtendedRoles, Rgb, SemanticPalette};
26pub use presets::{Preset, ThemeLoadError, resolve_palette};
27
28use ratatui::style::{Color, Modifier, Style};
29
30use crate::theme::color_mode::EffectiveColorMode as Ecm;
31use crate::theme::palette::SemanticPalette as Palette;
32
33pub struct SyntaxTheme {
52 pub keyword: Style,
54 pub string: Style,
56 pub comment: Style,
58 pub function: Style,
60 pub r#type: Style,
62 pub number: Style,
64 pub operator: Style,
66 pub variable: Style,
68 pub attribute: Style,
70 pub punctuation: Style,
72 pub constant: Style,
74 pub default: Style,
76}
77
78pub struct Theme {
100 pub user_message: Style,
102 pub assistant_message: Style,
104 pub system_message: Style,
106 pub input_text: Style,
108 pub input_cursor: Style,
110 pub status_bar: Style,
112 pub header: Style,
114 pub panel_border: Style,
116 pub panel_title: Style,
118 pub highlight: Style,
120 pub error: Style,
122 pub thinking_message: Style,
124 pub code_inline: Style,
126 pub code_block: Style,
128 pub streaming_cursor: Style,
130 pub tool_command: Style,
132 pub assistant_accent: Style,
134 pub tool_accent: Style,
136 pub diff_added_bg: Color,
138 pub diff_removed_bg: Color,
140 pub diff_word_added_bg: Color,
142 pub diff_word_removed_bg: Color,
144 pub diff_gutter_add: Style,
146 pub diff_gutter_remove: Style,
148 pub diff_header: Style,
150 pub link: Style,
152 pub table_border: Style,
154 pub user_message_bg: Color,
156 pub turn_separator: Style,
158 pub tool_success: Style,
160 pub tool_failure: Style,
162 pub syntax_theme: SyntaxTheme,
167}
168
169impl Theme {
170 #[must_use]
183 pub fn from_palette(p: &SemanticPalette) -> Self {
184 Self::from_palette_with_mode(p, Ecm::Truecolor)
185 }
186
187 #[must_use]
206 pub fn from_palette_with_mode(p: &Palette, mode: Ecm) -> Self {
207 let am = |style: Style| apply_mode(style, mode);
208 let mc = |color: Color| map_color(color, mode);
209
210 let fg = |c: Rgb| Color::from(c);
211
212 Self {
213 user_message: am(Style::default().fg(fg(p.accent))),
214 assistant_message: am(Style::default().fg(fg(p.text))),
215 system_message: am(Style::default().fg(fg(p.muted))),
216 input_text: am(Style::default().fg(fg(p.accent))),
217 input_cursor: am(Style::default()
218 .fg(fg(p.warning))
219 .add_modifier(Modifier::BOLD)),
220 status_bar: am(Style::default().fg(fg(p.text)).bg(fg(p.surface))),
221 header: am(Style::default()
222 .fg(fg(p.text))
223 .bg(fg(p.extended.header_bg))
224 .add_modifier(Modifier::BOLD)),
225 panel_border: am(Style::default().fg(fg(p.border))),
226 panel_title: am(Style::default().fg(fg(p.text)).add_modifier(Modifier::BOLD)),
227 highlight: am(Style::default().fg(fg(p.extended.highlight))),
228 error: am(Style::default().fg(fg(p.error))),
229 thinking_message: am(Style::default().fg(fg(p.muted))),
230 code_inline: am(Style::default()
231 .fg(fg(p.info))
232 .bg(fg(p.surface))
233 .add_modifier(Modifier::BOLD)),
234 code_block: am(Style::default().fg(fg(p.text)).bg(fg(p.surface))),
235 streaming_cursor: am(Style::default().fg(fg(p.muted))),
236 tool_command: am(Style::default()
237 .fg(fg(p.warning))
238 .add_modifier(Modifier::BOLD)),
239 assistant_accent: am(Style::default().fg(fg(p.extended.accent_alt))),
240 tool_accent: am(Style::default().fg(fg(p.extended.accent_alt))),
241 diff_added_bg: mc(Color::Rgb(0, 40, 0)),
243 diff_removed_bg: mc(Color::Rgb(40, 0, 0)),
244 diff_word_added_bg: mc(Color::Rgb(0, 80, 0)),
245 diff_word_removed_bg: mc(Color::Rgb(80, 0, 0)),
246 diff_gutter_add: am(Style::default().fg(fg(p.success))),
247 diff_gutter_remove: am(Style::default().fg(fg(p.error))),
248 diff_header: am(Style::default().fg(fg(p.muted))),
249 link: am(Style::default()
250 .fg(fg(p.accent))
251 .add_modifier(Modifier::UNDERLINED)),
252 table_border: am(Style::default().fg(fg(p.muted))),
253 user_message_bg: mc(fg(p.surface)),
254 turn_separator: am(Style::default().fg(fg(p.muted)).add_modifier(Modifier::DIM)),
255 tool_success: am(Style::default().fg(fg(p.success))),
256 tool_failure: am(Style::default().fg(fg(p.error))),
257 syntax_theme: SyntaxTheme::default(),
258 }
259 }
260}
261
262impl Default for Theme {
263 fn default() -> Self {
268 Self {
269 user_message: Style::default().fg(Color::Cyan),
270 assistant_message: Style::default().fg(Color::Rgb(200, 200, 210)),
271 system_message: Style::default().fg(Color::DarkGray),
272 input_text: Style::default().fg(Color::Cyan),
273 input_cursor: Style::default()
274 .fg(Color::Yellow)
275 .add_modifier(Modifier::BOLD),
276 status_bar: Style::default().fg(Color::White).bg(Color::DarkGray),
277 header: Style::default()
278 .fg(Color::Rgb(200, 220, 255))
279 .bg(Color::Rgb(20, 40, 80))
280 .add_modifier(Modifier::BOLD),
281 panel_border: Style::default().fg(Color::Gray),
282 panel_title: Style::default()
283 .fg(Color::White)
284 .add_modifier(Modifier::BOLD),
285 highlight: Style::default().fg(Color::Rgb(215, 150, 60)),
286 error: Style::default().fg(Color::Red),
287 thinking_message: Style::default().fg(Color::DarkGray),
288 code_inline: Style::default()
289 .fg(Color::Rgb(100, 180, 255))
290 .bg(Color::Rgb(15, 30, 55))
291 .add_modifier(Modifier::BOLD),
292 code_block: Style::default()
293 .fg(Color::Rgb(190, 175, 145))
294 .bg(Color::Rgb(20, 25, 35)),
295 streaming_cursor: Style::default().fg(Color::DarkGray),
296 tool_command: Style::default()
297 .fg(Color::Yellow)
298 .add_modifier(Modifier::BOLD),
299 assistant_accent: Style::default().fg(Color::Rgb(185, 85, 25)),
300 tool_accent: Style::default().fg(Color::Rgb(140, 120, 50)),
301 diff_added_bg: Color::Rgb(0, 40, 0),
302 diff_removed_bg: Color::Rgb(40, 0, 0),
303 diff_word_added_bg: Color::Rgb(0, 80, 0),
304 diff_word_removed_bg: Color::Rgb(80, 0, 0),
305 diff_gutter_add: Style::default().fg(Color::Green),
306 diff_gutter_remove: Style::default().fg(Color::Red),
307 diff_header: Style::default().fg(Color::DarkGray),
308 link: Style::default()
309 .fg(Color::Cyan)
310 .add_modifier(Modifier::UNDERLINED),
311 table_border: Style::default().fg(Color::DarkGray),
312 user_message_bg: Color::Rgb(20, 25, 35),
313 turn_separator: Style::default()
314 .fg(Color::DarkGray)
315 .add_modifier(Modifier::DIM),
316 tool_success: Style::default().fg(Color::Green),
317 tool_failure: Style::default().fg(Color::Red),
318 syntax_theme: SyntaxTheme::default(),
319 }
320 }
321}
322
323#[cfg(test)]
324mod tests {
325 use super::*;
326
327 #[test]
328 fn default_theme_has_distinct_message_styles() {
329 let theme = Theme::default();
330 assert_ne!(theme.user_message, theme.assistant_message);
331 assert_ne!(theme.assistant_message, theme.system_message);
332 }
333
334 #[test]
335 fn default_theme_status_bar_has_background() {
336 let theme = Theme::default();
337 assert_eq!(theme.status_bar.bg, Some(Color::DarkGray));
338 }
339
340 #[test]
342 fn from_palette_changes_user_message_fg() {
343 let mut p = SemanticPalette::zephyr();
344 p.accent = Rgb(0xAB, 0xCD, 0xEF);
346 let theme = Theme::from_palette(&p);
347 assert_ne!(
348 theme.user_message.fg,
349 Some(Color::Cyan),
350 "palette-derived theme must differ from Theme::default() user_message"
351 );
352 assert_eq!(theme.user_message.fg, Some(Color::Rgb(0xAB, 0xCD, 0xEF)));
353 }
354
355 #[test]
356 fn from_palette_never_strips_fg() {
357 let theme = Theme::from_palette_with_mode(&SemanticPalette::zephyr(), Ecm::Never);
358 assert_eq!(theme.user_message.fg, None);
360 assert_eq!(theme.status_bar.bg, None);
361 assert!(theme.header.add_modifier.contains(Modifier::BOLD));
363 }
364
365 #[test]
366 fn from_palette_ansi256_no_rgb_fg() {
367 let theme = Theme::from_palette_with_mode(&SemanticPalette::zephyr(), Ecm::Ansi256);
368 if let Some(fg) = theme.user_message.fg {
370 assert!(
371 !matches!(fg, Color::Rgb(..)),
372 "Ansi256 mode must downgrade Rgb: {fg:?}"
373 );
374 }
375 }
376
377 #[test]
379 fn zephyr_text_bg_contrast_aa() {
380 let p = SemanticPalette::zephyr();
381 let text_l = relative_luminance(p.text.0, p.text.1, p.text.2);
382 let bg_l = relative_luminance(p.bg.0, p.bg.1, p.bg.2);
383 let ratio = contrast_ratio(text_l, bg_l);
384 assert!(
385 ratio >= 4.5,
386 "zephyr text/bg WCAG contrast {ratio:.2}:1 is below AA threshold 4.5:1"
387 );
388 }
389
390 #[test]
391 fn high_contrast_text_bg_contrast_aaa() {
392 let p = presets::Preset::HighContrast.palette();
393 let text_l = relative_luminance(p.text.0, p.text.1, p.text.2);
394 let bg_l = relative_luminance(p.bg.0, p.bg.1, p.bg.2);
395 let ratio = contrast_ratio(text_l, bg_l);
396 assert!(
397 ratio >= 7.0,
398 "high-contrast text/bg WCAG contrast {ratio:.2}:1 is below AAA threshold 7.0:1"
399 );
400 }
401
402 fn srgb_linearize(u: u8) -> f64 {
403 let c = f64::from(u) / 255.0;
404 if c <= 0.04045 {
405 c / 12.92
406 } else {
407 ((c + 0.055) / 1.055).powf(2.4)
408 }
409 }
410
411 fn relative_luminance(r: u8, g: u8, b: u8) -> f64 {
412 0.2126 * srgb_linearize(r) + 0.7152 * srgb_linearize(g) + 0.0722 * srgb_linearize(b)
413 }
414
415 fn contrast_ratio(l1: f64, l2: f64) -> f64 {
416 let (lighter, darker) = if l1 > l2 { (l1, l2) } else { (l2, l1) };
417 (lighter + 0.05) / (darker + 0.05)
418 }
419}