1use ratatui::style::Color;
2use serde::{Deserialize, Serialize};
3
4#[derive(Debug, Clone, Serialize, Deserialize)]
6pub struct Theme {
7 pub name: String,
8 pub colors: ThemeColors,
9}
10
11#[derive(Debug, Clone, Serialize, Deserialize)]
12pub struct ThemeColors {
13 pub background: ColorValue,
15 pub foreground: ColorValue,
16
17 pub border: ColorValue,
19 pub border_focused: ColorValue,
20 pub header: ColorValue,
21 pub status_bar: ColorValue,
22
23 pub text_primary: ColorValue,
25 pub text_secondary: ColorValue,
26 pub text_disabled: ColorValue,
27 pub text_highlight: ColorValue,
28
29 pub user_message: ColorValue,
31 pub assistant_message: ColorValue,
32 pub system_message: ColorValue,
33 pub user_message_background: ColorValue,
37
38 pub code_background: ColorValue,
40 pub code_foreground: ColorValue,
41 pub code_keyword: ColorValue,
42 pub code_string: ColorValue,
43 pub code_comment: ColorValue,
44
45 pub mode_normal: ColorValue,
47 pub mode_accept_edits: ColorValue,
48 pub mode_bypass_all: ColorValue,
49
50 pub success: ColorValue,
52 pub warning: ColorValue,
53 pub error: ColorValue,
54 pub info: ColorValue,
55
56 #[serde(default = "default_brand")]
59 pub brand: ColorValue,
60
61 #[serde(default = "default_text_meta")]
65 pub text_meta: ColorValue,
66 #[serde(default = "default_diff_added_bg")]
68 pub diff_added_bg: ColorValue,
69 #[serde(default = "default_diff_removed_bg")]
71 pub diff_removed_bg: ColorValue,
72 #[serde(default = "default_queued_bg")]
75 pub queued_bg: ColorValue,
76}
77
78fn default_brand() -> ColorValue {
80 ColorValue::Rgb {
81 r: 34,
82 g: 211,
83 b: 238,
84 }
85}
86
87fn default_text_meta() -> ColorValue {
90 ColorValue::Rgb {
91 r: 136,
92 g: 136,
93 b: 136,
94 }
95}
96
97fn default_diff_added_bg() -> ColorValue {
98 ColorValue::Rgb {
99 r: 20,
100 g: 50,
101 b: 20,
102 }
103}
104
105fn default_diff_removed_bg() -> ColorValue {
106 ColorValue::Rgb {
107 r: 60,
108 g: 20,
109 b: 20,
110 }
111}
112
113fn default_queued_bg() -> ColorValue {
114 ColorValue::Rgb {
115 r: 60,
116 g: 60,
117 b: 80,
118 }
119}
120
121#[derive(Debug, Clone, Serialize, Deserialize)]
122#[serde(untagged)]
123pub enum ColorValue {
124 Rgb { r: u8, g: u8, b: u8 },
125 Named(String),
126}
127
128impl ColorValue {
129 #[must_use]
130 pub fn to_color(&self) -> Color {
131 match self {
132 Self::Rgb { r, g, b } => Color::Rgb(*r, *g, *b),
133 Self::Named(name) => match name.as_str() {
134 "default" => Color::Reset,
137 "black" => Color::Black,
138 "red" => Color::Red,
139 "green" => Color::Green,
140 "yellow" => Color::Yellow,
141 "blue" => Color::Blue,
142 "magenta" => Color::Magenta,
143 "cyan" => Color::Cyan,
144 "white" => Color::White,
145 "gray" | "grey" => Color::Gray,
146 "dark_gray" | "dark_grey" => Color::DarkGray,
147 _ => Color::White,
148 },
149 }
150 }
151}
152
153impl Theme {
154 #[must_use]
157 pub fn light() -> Self {
158 Self {
159 name: "Light".to_string(),
160 colors: ThemeColors {
161 background: ColorValue::Rgb {
162 r: 250,
163 g: 250,
164 b: 250,
165 },
166 foreground: ColorValue::Rgb {
167 r: 30,
168 g: 30,
169 b: 30,
170 },
171
172 border: ColorValue::Named("gray".to_string()),
173 border_focused: ColorValue::Named("blue".to_string()),
174 header: ColorValue::Named("blue".to_string()),
175 status_bar: ColorValue::Named("white".to_string()),
176
177 text_primary: ColorValue::Named("black".to_string()),
178 text_secondary: ColorValue::Named("dark_gray".to_string()),
179 text_disabled: ColorValue::Named("gray".to_string()),
180 text_highlight: ColorValue::Named("magenta".to_string()),
181
182 user_message: ColorValue::Named("blue".to_string()),
183 assistant_message: ColorValue::Named("green".to_string()),
184 system_message: ColorValue::Named("yellow".to_string()),
185 user_message_background: ColorValue::Rgb {
186 r: 230,
187 g: 230,
188 b: 230,
189 },
190
191 code_background: ColorValue::Rgb {
192 r: 240,
193 g: 240,
194 b: 240,
195 },
196 code_foreground: ColorValue::Named("dark_gray".to_string()),
197 code_keyword: ColorValue::Named("magenta".to_string()),
198 code_string: ColorValue::Named("green".to_string()),
199 code_comment: ColorValue::Named("gray".to_string()),
200
201 mode_normal: ColorValue::Named("green".to_string()),
202 mode_accept_edits: ColorValue::Named("yellow".to_string()),
203 mode_bypass_all: ColorValue::Named("red".to_string()),
204
205 success: ColorValue::Named("green".to_string()),
206 warning: ColorValue::Named("yellow".to_string()),
207 error: ColorValue::Named("red".to_string()),
208 info: ColorValue::Named("blue".to_string()),
209
210 brand: default_brand(),
211 text_meta: ColorValue::Rgb {
212 r: 110,
213 g: 110,
214 b: 110,
215 },
216 diff_added_bg: ColorValue::Rgb {
217 r: 220,
218 g: 245,
219 b: 220,
220 },
221 diff_removed_bg: ColorValue::Rgb {
222 r: 250,
223 g: 225,
224 b: 225,
225 },
226 queued_bg: ColorValue::Rgb {
227 r: 225,
228 g: 225,
229 b: 240,
230 },
231 },
232 }
233 }
234
235 #[must_use]
237 pub fn dark() -> Self {
238 Self {
239 name: "Dark".to_string(),
240 colors: ThemeColors {
241 background: ColorValue::Rgb {
242 r: 20,
243 g: 20,
244 b: 20,
245 },
246 foreground: ColorValue::Rgb {
247 r: 230,
248 g: 230,
249 b: 230,
250 },
251
252 border: ColorValue::Named("dark_gray".to_string()),
253 border_focused: ColorValue::Named("cyan".to_string()),
254 header: ColorValue::Named("cyan".to_string()),
255 status_bar: ColorValue::Named("black".to_string()),
256
257 text_primary: ColorValue::Named("white".to_string()),
258 text_secondary: ColorValue::Named("gray".to_string()),
259 text_disabled: ColorValue::Named("dark_gray".to_string()),
260 text_highlight: ColorValue::Named("yellow".to_string()),
261
262 user_message: ColorValue::Named("blue".to_string()),
263 assistant_message: ColorValue::Named("green".to_string()),
264 system_message: ColorValue::Named("yellow".to_string()),
265 user_message_background: ColorValue::Rgb {
266 r: 54,
267 g: 54,
268 b: 54,
269 },
270
271 code_background: ColorValue::Rgb {
272 r: 40,
273 g: 40,
274 b: 40,
275 },
276 code_foreground: ColorValue::Named("gray".to_string()),
277 code_keyword: ColorValue::Named("magenta".to_string()),
278 code_string: ColorValue::Named("green".to_string()),
279 code_comment: ColorValue::Named("dark_gray".to_string()),
280
281 mode_normal: ColorValue::Named("green".to_string()),
282 mode_accept_edits: ColorValue::Named("yellow".to_string()),
283 mode_bypass_all: ColorValue::Named("red".to_string()),
284
285 success: ColorValue::Named("green".to_string()),
286 warning: ColorValue::Named("yellow".to_string()),
287 error: ColorValue::Named("red".to_string()),
288 info: ColorValue::Named("cyan".to_string()),
289
290 brand: default_brand(),
291 text_meta: default_text_meta(),
292 diff_added_bg: default_diff_added_bg(),
293 diff_removed_bg: default_diff_removed_bg(),
294 queued_bg: default_queued_bg(),
295 },
296 }
297 }
298
299 #[must_use]
304 pub fn plain() -> Self {
305 fn d() -> ColorValue {
306 ColorValue::Named("default".to_string())
307 }
308 Self {
309 name: "Plain".to_string(),
310 colors: ThemeColors {
311 background: d(),
312 foreground: d(),
313 border: d(),
314 border_focused: d(),
315 header: d(),
316 status_bar: d(),
317 text_primary: d(),
318 text_secondary: d(),
319 text_disabled: d(),
320 text_highlight: d(),
321 user_message: d(),
322 assistant_message: d(),
323 system_message: d(),
324 user_message_background: d(),
325 code_background: d(),
326 code_foreground: d(),
327 code_keyword: d(),
328 code_string: d(),
329 code_comment: d(),
330 mode_normal: d(),
331 mode_accept_edits: d(),
332 mode_bypass_all: d(),
333 success: d(),
334 warning: d(),
335 error: d(),
336 info: d(),
337 brand: d(),
338 text_meta: d(),
339 diff_added_bg: d(),
340 diff_removed_bg: d(),
341 queued_bg: d(),
342 },
343 }
344 }
345}
346
347#[cfg(test)]
348mod tests {
349 use super::*;
350
351 #[test]
352 fn named_default_maps_to_reset() {
353 assert_eq!(
354 ColorValue::Named("default".to_string()).to_color(),
355 Color::Reset
356 );
357 }
358
359 #[test]
360 fn plain_theme_is_entirely_reset() {
361 let theme = Theme::plain();
366 let json = serde_json::to_value(&theme.colors).unwrap();
367 let obj = json.as_object().unwrap();
368 assert!(!obj.is_empty());
369 for (field, value) in obj {
370 assert_eq!(
371 value.as_str(),
372 Some("default"),
373 "plain theme leaks color through `{field}`: {value}"
374 );
375 }
376 }
377
378 #[test]
379 fn dark_and_light_populate_the_new_slots() {
380 for theme in [Theme::dark(), Theme::light()] {
381 assert_ne!(theme.colors.diff_added_bg.to_color(), Color::Reset);
382 assert_ne!(theme.colors.diff_removed_bg.to_color(), Color::Reset);
383 assert_ne!(theme.colors.queued_bg.to_color(), Color::Reset);
384 assert_ne!(theme.colors.text_meta.to_color(), Color::Reset);
385 }
386 }
387
388 #[test]
389 fn theme_colors_deserialize_defaults_new_fields() {
390 let dark = Theme::dark();
393 let mut json = serde_json::to_value(&dark.colors).unwrap();
394 let obj = json.as_object_mut().unwrap();
395 for field in ["text_meta", "diff_added_bg", "diff_removed_bg", "queued_bg"] {
396 obj.remove(field);
397 }
398 let colors: ThemeColors = serde_json::from_value(json).unwrap();
399 assert_eq!(
400 colors.text_meta.to_color(),
401 dark.colors.text_meta.to_color()
402 );
403 assert_eq!(
404 colors.diff_added_bg.to_color(),
405 dark.colors.diff_added_bg.to_color()
406 );
407 }
408}