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 #[must_use]
131 pub fn to_color(&self) -> Color {
132 match self {
133 Self::Rgb { r, g, b } => Color::Rgb(*r, *g, *b),
134 Self::Named(name) => match name.as_str() {
135 "default" => Color::Reset,
138 "black" => Color::Black,
139 "red" => Color::Red,
140 "green" => Color::Green,
141 "yellow" => Color::Yellow,
142 "blue" => Color::Blue,
143 "magenta" => Color::Magenta,
144 "cyan" => Color::Cyan,
145 "white" => Color::White,
146 "gray" | "grey" => Color::Gray,
147 "dark_gray" | "dark_grey" => Color::DarkGray,
148 _ => Color::White,
149 },
150 }
151 }
152}
153
154impl Theme {
155 #[must_use]
158 pub fn light() -> Self {
159 Self {
160 name: "Light".to_string(),
161 colors: ThemeColors {
162 background: ColorValue::Rgb {
163 r: 250,
164 g: 250,
165 b: 250,
166 },
167 foreground: ColorValue::Rgb {
168 r: 30,
169 g: 30,
170 b: 30,
171 },
172
173 border: ColorValue::Named("gray".to_string()),
174 border_focused: ColorValue::Named("blue".to_string()),
175 header: ColorValue::Named("blue".to_string()),
176 status_bar: ColorValue::Named("white".to_string()),
177
178 text_primary: ColorValue::Named("black".to_string()),
179 text_secondary: ColorValue::Named("dark_gray".to_string()),
180 text_disabled: ColorValue::Named("gray".to_string()),
181 text_highlight: ColorValue::Named("magenta".to_string()),
182
183 user_message: ColorValue::Named("blue".to_string()),
184 assistant_message: ColorValue::Named("green".to_string()),
185 system_message: ColorValue::Named("yellow".to_string()),
186 user_message_background: ColorValue::Rgb {
187 r: 230,
188 g: 230,
189 b: 230,
190 },
191
192 code_background: ColorValue::Rgb {
193 r: 240,
194 g: 240,
195 b: 240,
196 },
197 code_foreground: ColorValue::Named("dark_gray".to_string()),
198 code_keyword: ColorValue::Named("magenta".to_string()),
199 code_string: ColorValue::Named("green".to_string()),
200 code_comment: ColorValue::Named("gray".to_string()),
201
202 mode_normal: ColorValue::Named("green".to_string()),
203 mode_accept_edits: ColorValue::Named("yellow".to_string()),
204 mode_plan: ColorValue::Named("blue".to_string()),
205 mode_bypass_all: ColorValue::Named("red".to_string()),
206
207 success: ColorValue::Named("green".to_string()),
208 warning: ColorValue::Named("yellow".to_string()),
209 error: ColorValue::Named("red".to_string()),
210 info: ColorValue::Named("blue".to_string()),
211
212 brand: default_brand(),
213 text_meta: ColorValue::Rgb {
214 r: 110,
215 g: 110,
216 b: 110,
217 },
218 diff_added_bg: ColorValue::Rgb {
219 r: 220,
220 g: 245,
221 b: 220,
222 },
223 diff_removed_bg: ColorValue::Rgb {
224 r: 250,
225 g: 225,
226 b: 225,
227 },
228 queued_bg: ColorValue::Rgb {
229 r: 225,
230 g: 225,
231 b: 240,
232 },
233 },
234 }
235 }
236
237 #[must_use]
239 pub fn dark() -> Self {
240 Self {
241 name: "Dark".to_string(),
242 colors: ThemeColors {
243 background: ColorValue::Rgb {
244 r: 20,
245 g: 20,
246 b: 20,
247 },
248 foreground: ColorValue::Rgb {
249 r: 230,
250 g: 230,
251 b: 230,
252 },
253
254 border: ColorValue::Named("dark_gray".to_string()),
255 border_focused: ColorValue::Named("cyan".to_string()),
256 header: ColorValue::Named("cyan".to_string()),
257 status_bar: ColorValue::Named("black".to_string()),
258
259 text_primary: ColorValue::Named("white".to_string()),
260 text_secondary: ColorValue::Named("gray".to_string()),
261 text_disabled: ColorValue::Named("dark_gray".to_string()),
262 text_highlight: ColorValue::Named("yellow".to_string()),
263
264 user_message: ColorValue::Named("blue".to_string()),
265 assistant_message: ColorValue::Named("green".to_string()),
266 system_message: ColorValue::Named("yellow".to_string()),
267 user_message_background: ColorValue::Rgb {
268 r: 54,
269 g: 54,
270 b: 54,
271 },
272
273 code_background: ColorValue::Rgb {
274 r: 40,
275 g: 40,
276 b: 40,
277 },
278 code_foreground: ColorValue::Named("gray".to_string()),
279 code_keyword: ColorValue::Named("magenta".to_string()),
280 code_string: ColorValue::Named("green".to_string()),
281 code_comment: ColorValue::Named("dark_gray".to_string()),
282
283 mode_normal: ColorValue::Named("green".to_string()),
284 mode_accept_edits: ColorValue::Named("yellow".to_string()),
285 mode_plan: ColorValue::Named("blue".to_string()),
286 mode_bypass_all: ColorValue::Named("red".to_string()),
287
288 success: ColorValue::Named("green".to_string()),
289 warning: ColorValue::Named("yellow".to_string()),
290 error: ColorValue::Named("red".to_string()),
291 info: ColorValue::Named("cyan".to_string()),
292
293 brand: default_brand(),
294 text_meta: default_text_meta(),
295 diff_added_bg: default_diff_added_bg(),
296 diff_removed_bg: default_diff_removed_bg(),
297 queued_bg: default_queued_bg(),
298 },
299 }
300 }
301
302 #[must_use]
307 pub fn plain() -> Self {
308 fn d() -> ColorValue {
309 ColorValue::Named("default".to_string())
310 }
311 Self {
312 name: "Plain".to_string(),
313 colors: ThemeColors {
314 background: d(),
315 foreground: d(),
316 border: d(),
317 border_focused: d(),
318 header: d(),
319 status_bar: d(),
320 text_primary: d(),
321 text_secondary: d(),
322 text_disabled: d(),
323 text_highlight: d(),
324 user_message: d(),
325 assistant_message: d(),
326 system_message: d(),
327 user_message_background: d(),
328 code_background: d(),
329 code_foreground: d(),
330 code_keyword: d(),
331 code_string: d(),
332 code_comment: d(),
333 mode_normal: d(),
334 mode_accept_edits: d(),
335 mode_plan: d(),
336 mode_bypass_all: d(),
337 success: d(),
338 warning: d(),
339 error: d(),
340 info: d(),
341 brand: d(),
342 text_meta: d(),
343 diff_added_bg: d(),
344 diff_removed_bg: d(),
345 queued_bg: d(),
346 },
347 }
348 }
349}
350
351#[cfg(test)]
352mod tests {
353 use super::*;
354
355 #[test]
356 fn named_default_maps_to_reset() {
357 assert_eq!(
358 ColorValue::Named("default".to_string()).to_color(),
359 Color::Reset
360 );
361 }
362
363 #[test]
364 fn plain_theme_is_entirely_reset() {
365 let theme = Theme::plain();
370 let json = serde_json::to_value(&theme.colors).unwrap();
371 let obj = json.as_object().unwrap();
372 assert!(!obj.is_empty());
373 for (field, value) in obj {
374 assert_eq!(
375 value.as_str(),
376 Some("default"),
377 "plain theme leaks color through `{field}`: {value}"
378 );
379 }
380 }
381
382 #[test]
383 fn dark_and_light_populate_the_new_slots() {
384 for theme in [Theme::dark(), Theme::light()] {
385 assert_ne!(theme.colors.diff_added_bg.to_color(), Color::Reset);
386 assert_ne!(theme.colors.diff_removed_bg.to_color(), Color::Reset);
387 assert_ne!(theme.colors.queued_bg.to_color(), Color::Reset);
388 assert_ne!(theme.colors.text_meta.to_color(), Color::Reset);
389 }
390 }
391
392 #[test]
393 fn theme_colors_deserialize_defaults_new_fields() {
394 let dark = Theme::dark();
397 let mut json = serde_json::to_value(&dark.colors).unwrap();
398 let obj = json.as_object_mut().unwrap();
399 for field in ["text_meta", "diff_added_bg", "diff_removed_bg", "queued_bg"] {
400 obj.remove(field);
401 }
402 let colors: ThemeColors = serde_json::from_value(json).unwrap();
403 assert_eq!(
404 colors.text_meta.to_color(),
405 dark.colors.text_meta.to_color()
406 );
407 assert_eq!(
408 colors.diff_added_bg.to_color(),
409 dark.colors.diff_added_bg.to_color()
410 );
411 }
412}