oxi_tui/theme/capability/
mod.rs1mod detect;
12
13use ratatui::style::Color;
14
15use crate::theme::{ColorScheme, Theme};
16
17#[derive(Debug, Clone, Copy, PartialEq, Eq)]
19pub enum ImageProtocol {
20 Kitty,
22 ITerm2,
24}
25
26#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Default)]
31pub enum ColorLevel {
32 #[default]
34 None,
35 Basic,
37 Ansi256,
39 TrueColor,
41}
42
43impl ColorLevel {
44 #[must_use]
46 pub fn has_color(self) -> bool {
47 self >= Self::Basic
48 }
49
50 #[must_use]
52 pub fn has_256(self) -> bool {
53 self >= Self::Ansi256
54 }
55
56 #[must_use]
58 pub fn has_truecolor(self) -> bool {
59 self >= Self::TrueColor
60 }
61}
62#[allow(clippy::struct_excessive_bools)] #[derive(Debug, Clone)]
65pub struct TerminalCaps {
66 pub color_level: ColorLevel,
69 pub image_protocol: Option<ImageProtocol>,
71 pub true_color: bool,
73 pub hyperlinks: bool,
75 pub kitty_protocol: bool,
77 pub sixel: bool,
79 pub synchronized_output: bool,
82 pub deccara: bool,
86 pub cell_size: Option<(u16, u16)>,
88 pub terminal_name: Option<String>,
90}
91
92impl Default for TerminalCaps {
93 fn default() -> Self {
94 Self {
97 color_level: ColorLevel::None,
98 image_protocol: None,
99 true_color: false,
100 hyperlinks: false,
101 kitty_protocol: false,
102 sixel: false,
103 synchronized_output: true,
104 deccara: false,
105 cell_size: None,
106 terminal_name: None,
107 }
108 }
109}
110
111impl TerminalCaps {
112 #[must_use]
119 pub fn detect() -> Self {
120 detect::detect_with_env()
121 }
122
123 pub fn adapt_theme(&self, theme: &mut Theme) {
129 if self.color_level >= ColorLevel::TrueColor {
130 return; }
132 adapt_color_scheme(&mut theme.colors, self.color_level);
133 theme.styles = crate::theme::ThemeStyles::from_colors(&theme.colors);
134 }
135
136 #[must_use]
138 pub fn supports_images(&self) -> bool {
139 self.image_protocol.is_some()
140 }
141}
142
143#[allow(clippy::match_same_arms)] #[must_use]
151pub fn adapt_color(color: Color, level: ColorLevel) -> Color {
152 match (color, level) {
153 (_, ColorLevel::None) => Color::Reset,
155 (Color::Reset, _) => Color::Reset,
157 (c, ColorLevel::TrueColor) => c,
159 (Color::Rgb(r, g, b), ColorLevel::Ansi256) => Color::Indexed(rgb_to_ansi256(r, g, b)),
161 (Color::Rgb(r, g, b), ColorLevel::Basic) => {
162 Color::Indexed(ansi256_to_basic(rgb_to_ansi256(r, g, b)))
163 }
164 (Color::DarkGray, ColorLevel::Basic | ColorLevel::Ansi256) => Color::Gray,
166 (
168 c @ (Color::LightRed
169 | Color::LightGreen
170 | Color::LightYellow
171 | Color::LightBlue
172 | Color::LightMagenta
173 | Color::LightCyan),
174 ColorLevel::Basic,
175 ) => darken_light(c),
176 (Color::Indexed(idx), ColorLevel::Basic) => Color::Indexed(ansi256_to_basic(idx)),
178 (
181 c @ (Color::Black
182 | Color::Red
183 | Color::Green
184 | Color::Yellow
185 | Color::Blue
186 | Color::Magenta
187 | Color::Cyan
188 | Color::White
189 | Color::Gray
190 | Color::LightRed
191 | Color::LightGreen
192 | Color::LightYellow
193 | Color::LightBlue
194 | Color::LightMagenta
195 | Color::LightCyan
196 | Color::Indexed(_)),
197 ColorLevel::Basic | ColorLevel::Ansi256,
198 ) => c,
199 }
200}
201
202fn darken_light(c: Color) -> Color {
203 match c {
204 Color::LightRed => Color::Red,
205 Color::LightGreen => Color::Green,
206 Color::LightYellow => Color::Yellow,
207 Color::LightBlue => Color::Blue,
208 Color::LightMagenta => Color::Magenta,
209 Color::LightCyan => Color::Cyan,
210 _ => c,
211 }
212}
213
214fn adapt_color_scheme(scheme: &mut ColorScheme, level: ColorLevel) {
215 macro_rules! adapt_field {
216 ($f:ident) => {
217 scheme.$f = adapt_color(scheme.$f, level);
218 };
219 }
220 adapt_field!(background);
221 adapt_field!(foreground);
222 adapt_field!(primary);
223 adapt_field!(accent);
224 adapt_field!(muted);
225 adapt_field!(border);
226 adapt_field!(user);
227 adapt_field!(user_bg);
228 adapt_field!(response);
229 adapt_field!(response_bg);
230 adapt_field!(thinking);
231 adapt_field!(thinking_bg);
232 adapt_field!(tool);
233 adapt_field!(tool_bg);
234 adapt_field!(success);
235 adapt_field!(warning);
236 adapt_field!(error);
237 adapt_field!(info);
238 adapt_field!(diff_add);
239 adapt_field!(diff_remove);
240 adapt_field!(diff_hunk);
241 adapt_field!(surface_bg);
242 adapt_field!(panel_bg);
243 adapt_field!(code_bg);
244 adapt_field!(selection_bg);
245 adapt_field!(diff_add_bg);
246 adapt_field!(diff_remove_bg);
247 adapt_field!(diff_hunk_bg);
248}
249
250fn rgb_to_ansi256(r: u8, g: u8, b: u8) -> u8 {
252 if r == g && g == b {
253 if r < 8 {
254 return 16;
255 }
256 if r > 248 {
257 return 231;
258 }
259 return u8::try_from(u16::from(r - 8) * 24 / 237 + 232).unwrap_or(232);
260 }
261 let ri = u16::from(r) * 5 / 255;
262 let gi = u16::from(g) * 5 / 255;
263 let bi = u16::from(b) * 5 / 255;
264 u8::try_from(16 + 36 * ri + 6 * gi + bi).unwrap_or(16)
265}
266
267fn ansi256_to_basic(idx: u8) -> u8 {
269 match idx {
270 0..=15 => idx, 16 => 0, 17..=51 => 4, 52..=87 | 124..=159 => 1, 88..=123 => 5, 160..=195 => 3, 196..=231 => {
277 if idx < 214 {
278 1
279 } else {
280 3
281 }
282 } 232..=255 => 7, }
285}
286
287#[cfg(test)]
288mod tests {
289 use super::*;
290
291 #[test]
292 fn adapt_color_resets_at_none_level() {
293 assert_eq!(adapt_color(Color::Red, ColorLevel::None), Color::Reset);
294 assert_eq!(
295 adapt_color(Color::Rgb(1, 2, 3), ColorLevel::None),
296 Color::Reset
297 );
298 assert_eq!(
299 adapt_color(Color::Indexed(42), ColorLevel::None),
300 Color::Reset
301 );
302 }
303
304 #[test]
305 fn adapt_color_keeps_truecolor_unchanged() {
306 let c = Color::Rgb(123, 45, 67);
307 assert_eq!(adapt_color(c, ColorLevel::TrueColor), c);
308 assert_eq!(adapt_color(Color::Red, ColorLevel::TrueColor), Color::Red);
309 }
310
311 #[test]
312 fn adapt_color_rgb_to_ansi256_red() {
313 assert_eq!(
314 adapt_color(Color::Rgb(255, 0, 0), ColorLevel::Ansi256),
315 Color::Indexed(196)
316 );
317 }
318
319 #[test]
320 fn adapt_color_keeps_basic_colors() {
321 assert_eq!(adapt_color(Color::Red, ColorLevel::Basic), Color::Red);
322 assert_eq!(adapt_color(Color::Cyan, ColorLevel::Basic), Color::Cyan);
323 assert_eq!(adapt_color(Color::DarkGray, ColorLevel::Basic), Color::Gray);
325 }
326
327 #[test]
328 fn adapt_color_collapses_light_to_basic_at_basic_level() {
329 assert_eq!(adapt_color(Color::LightRed, ColorLevel::Basic), Color::Red);
331 assert_eq!(
332 adapt_color(Color::LightCyan, ColorLevel::Basic),
333 Color::Cyan
334 );
335 assert_eq!(
337 adapt_color(Color::LightRed, ColorLevel::Ansi256),
338 Color::LightRed
339 );
340 }
341
342 #[test]
343 fn adapt_color_rgb_to_basic_via_index() {
344 assert_eq!(
346 adapt_color(Color::Rgb(255, 0, 0), ColorLevel::Basic),
347 Color::Indexed(1)
348 );
349 }
350
351 #[test]
352 fn no_color_env_returns_none_level() {
353 let level = detect::detect_color_level(Some("1"), "truecolor", "xterm-256color", true);
357 assert_eq!(level, ColorLevel::None);
358 }
359
360 #[test]
361 fn color_level_has_helpers() {
362 assert!(!ColorLevel::None.has_color());
363 assert!(ColorLevel::Basic.has_color());
364 assert!(!ColorLevel::Basic.has_256());
365 assert!(ColorLevel::Ansi256.has_256());
366 assert!(!ColorLevel::Ansi256.has_truecolor());
367 assert!(ColorLevel::TrueColor.has_truecolor());
368 }
369
370 #[test]
371 fn adapt_theme_skips_downgrade_at_truecolor() {
372 let mut theme = Theme::dark();
373 let original_bg = theme.colors.background;
374 let caps = TerminalCaps {
375 color_level: ColorLevel::TrueColor,
376 ..TerminalCaps::default()
377 };
378 caps.adapt_theme(&mut theme);
379 assert_eq!(theme.colors.background, original_bg);
380 }
381
382 #[test]
383 fn adapt_theme_downgrades_rgb_to_ansi256() {
384 let mut theme = Theme::dark();
385 theme.colors.accent = Color::Rgb(255, 0, 0);
387 let caps = TerminalCaps {
388 color_level: ColorLevel::Ansi256,
389 ..TerminalCaps::default()
390 };
391 caps.adapt_theme(&mut theme);
392 assert_eq!(theme.colors.accent, Color::Indexed(196));
393 assert_eq!(theme.styles.accent.fg, Some(Color::Indexed(196)));
395 }
396
397 #[test]
398 fn adapt_theme_at_none_resets_all_to_default_bg() {
399 let mut theme = Theme::dark();
400 let caps = TerminalCaps {
401 color_level: ColorLevel::None,
402 ..TerminalCaps::default()
403 };
404 caps.adapt_theme(&mut theme);
405 assert_eq!(theme.colors.background, Color::Reset);
407 assert_eq!(theme.colors.foreground, Color::Reset);
408 assert_eq!(theme.colors.primary, Color::Reset);
409 assert_eq!(theme.colors.accent, Color::Reset);
410 }
411
412 #[test]
413 fn rgb_to_ansi256_pure_red_is_196() {
414 assert_eq!(rgb_to_ansi256(255, 0, 0), 196);
415 }
416
417 #[test]
418 fn ansi256_to_basic_known_indices() {
419 assert_eq!(ansi256_to_basic(196), 1);
421 assert_eq!(ansi256_to_basic(21), 4);
423 assert_eq!(ansi256_to_basic(0), 0);
425 assert_eq!(ansi256_to_basic(15), 15);
427 assert_eq!(ansi256_to_basic(240), 7);
429 }
430}