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_plan: ColorValue,
49 pub mode_bypass_all: ColorValue,
50
51 pub success: ColorValue,
53 pub warning: ColorValue,
54 pub error: ColorValue,
55 pub info: ColorValue,
56
57 #[serde(default = "default_brand")]
60 pub brand: ColorValue,
61
62 #[serde(default = "default_text_meta")]
66 pub text_meta: ColorValue,
67 #[serde(default = "default_diff_added_bg")]
69 pub diff_added_bg: ColorValue,
70 #[serde(default = "default_diff_removed_bg")]
72 pub diff_removed_bg: ColorValue,
73 #[serde(default = "default_queued_bg")]
76 pub queued_bg: ColorValue,
77}
78
79fn default_brand() -> ColorValue {
81 ColorValue::Rgb {
82 r: 34,
83 g: 211,
84 b: 238,
85 }
86}
87
88fn default_text_meta() -> ColorValue {
91 ColorValue::Rgb {
92 r: 136,
93 g: 136,
94 b: 136,
95 }
96}
97
98fn default_diff_added_bg() -> ColorValue {
99 ColorValue::Rgb {
100 r: 20,
101 g: 50,
102 b: 20,
103 }
104}
105
106fn default_diff_removed_bg() -> ColorValue {
107 ColorValue::Rgb {
108 r: 60,
109 g: 20,
110 b: 20,
111 }
112}
113
114fn default_queued_bg() -> ColorValue {
115 ColorValue::Rgb {
116 r: 60,
117 g: 60,
118 b: 80,
119 }
120}
121
122#[derive(Debug, Clone, Serialize, Deserialize)]
123#[serde(untagged)]
124pub enum ColorValue {
125 Rgb { r: u8, g: u8, b: u8 },
126 Named(String),
127}
128
129impl ColorValue {
130 pub fn to_color(&self) -> Color {
131 match self {
132 ColorValue::Rgb { r, g, b } => Color::Rgb(*r, *g, *b),
133 ColorValue::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 pub fn light() -> Self {
157 Self {
158 name: "Light".to_string(),
159 colors: ThemeColors {
160 background: ColorValue::Rgb {
161 r: 250,
162 g: 250,
163 b: 250,
164 },
165 foreground: ColorValue::Rgb {
166 r: 30,
167 g: 30,
168 b: 30,
169 },
170
171 border: ColorValue::Named("gray".to_string()),
172 border_focused: ColorValue::Named("blue".to_string()),
173 header: ColorValue::Named("blue".to_string()),
174 status_bar: ColorValue::Named("white".to_string()),
175
176 text_primary: ColorValue::Named("black".to_string()),
177 text_secondary: ColorValue::Named("dark_gray".to_string()),
178 text_disabled: ColorValue::Named("gray".to_string()),
179 text_highlight: ColorValue::Named("magenta".to_string()),
180
181 user_message: ColorValue::Named("blue".to_string()),
182 assistant_message: ColorValue::Named("green".to_string()),
183 system_message: ColorValue::Named("yellow".to_string()),
184 user_message_background: ColorValue::Rgb {
185 r: 230,
186 g: 230,
187 b: 230,
188 },
189
190 code_background: ColorValue::Rgb {
191 r: 240,
192 g: 240,
193 b: 240,
194 },
195 code_foreground: ColorValue::Named("dark_gray".to_string()),
196 code_keyword: ColorValue::Named("magenta".to_string()),
197 code_string: ColorValue::Named("green".to_string()),
198 code_comment: ColorValue::Named("gray".to_string()),
199
200 mode_normal: ColorValue::Named("green".to_string()),
201 mode_accept_edits: ColorValue::Named("yellow".to_string()),
202 mode_plan: ColorValue::Named("blue".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 pub fn dark() -> Self {
237 Self {
238 name: "Dark".to_string(),
239 colors: ThemeColors {
240 background: ColorValue::Rgb {
241 r: 20,
242 g: 20,
243 b: 20,
244 },
245 foreground: ColorValue::Rgb {
246 r: 230,
247 g: 230,
248 b: 230,
249 },
250
251 border: ColorValue::Named("dark_gray".to_string()),
252 border_focused: ColorValue::Named("cyan".to_string()),
253 header: ColorValue::Named("cyan".to_string()),
254 status_bar: ColorValue::Named("black".to_string()),
255
256 text_primary: ColorValue::Named("white".to_string()),
257 text_secondary: ColorValue::Named("gray".to_string()),
258 text_disabled: ColorValue::Named("dark_gray".to_string()),
259 text_highlight: ColorValue::Named("yellow".to_string()),
260
261 user_message: ColorValue::Named("blue".to_string()),
262 assistant_message: ColorValue::Named("green".to_string()),
263 system_message: ColorValue::Named("yellow".to_string()),
264 user_message_background: ColorValue::Rgb {
265 r: 54,
266 g: 54,
267 b: 54,
268 },
269
270 code_background: ColorValue::Rgb {
271 r: 40,
272 g: 40,
273 b: 40,
274 },
275 code_foreground: ColorValue::Named("gray".to_string()),
276 code_keyword: ColorValue::Named("magenta".to_string()),
277 code_string: ColorValue::Named("green".to_string()),
278 code_comment: ColorValue::Named("dark_gray".to_string()),
279
280 mode_normal: ColorValue::Named("green".to_string()),
281 mode_accept_edits: ColorValue::Named("yellow".to_string()),
282 mode_plan: ColorValue::Named("blue".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 pub fn plain() -> Self {
304 fn d() -> ColorValue {
305 ColorValue::Named("default".to_string())
306 }
307 Self {
308 name: "Plain".to_string(),
309 colors: ThemeColors {
310 background: d(),
311 foreground: d(),
312 border: d(),
313 border_focused: d(),
314 header: d(),
315 status_bar: d(),
316 text_primary: d(),
317 text_secondary: d(),
318 text_disabled: d(),
319 text_highlight: d(),
320 user_message: d(),
321 assistant_message: d(),
322 system_message: d(),
323 user_message_background: d(),
324 code_background: d(),
325 code_foreground: d(),
326 code_keyword: d(),
327 code_string: d(),
328 code_comment: d(),
329 mode_normal: d(),
330 mode_accept_edits: d(),
331 mode_plan: 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}