1use std::fmt::Write;
13
14mod catppuccin;
15mod default;
16mod dracula;
17mod gruvbox;
18mod minimal;
19mod nord;
20mod rose_pine;
21pub mod style_syntax;
22mod tokyo_night;
23pub mod user;
24
25pub use style_syntax::{parse_style, StyleParseError};
26pub use user::{RegisteredTheme, ThemeRegistry, ThemeSource};
27
28#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
32pub enum Role {
33 Foreground = 0,
35 Background = 1,
36 Muted = 2,
37 Primary = 3,
38 Accent = 4,
39 Success = 5,
40 Warning = 6,
41 Error = 7,
42 Info = 8,
43 SuccessDim = 9,
46 WarningDim = 10,
47 ErrorDim = 11,
48 PrimaryDim = 12,
49 AccentDim = 13,
50 Surface = 14,
51 Border = 15,
52}
53
54const _: () = assert!(Role::Border as usize == Role::COUNT - 1);
58
59impl Role {
60 pub const COUNT: usize = 16;
63
64 #[must_use]
67 pub fn fallback(self) -> Role {
68 match self {
69 Self::SuccessDim => Self::Success,
70 Self::WarningDim => Self::Warning,
71 Self::ErrorDim => Self::Error,
72 Self::PrimaryDim => Self::Primary,
73 Self::AccentDim => Self::Accent,
74 Self::Surface => Self::Background,
75 Self::Border => Self::Muted,
76 other => other,
77 }
78 }
79}
80
81#[derive(Debug, Clone, Copy, PartialEq, Eq)]
84pub enum AnsiColor {
85 Black = 0,
86 Red = 1,
87 Green = 2,
88 Yellow = 3,
89 Blue = 4,
90 Magenta = 5,
91 Cyan = 6,
92 White = 7,
93 BrightBlack = 8,
94 BrightRed = 9,
95 BrightGreen = 10,
96 BrightYellow = 11,
97 BrightBlue = 12,
98 BrightMagenta = 13,
99 BrightCyan = 14,
100 BrightWhite = 15,
101}
102
103#[derive(Debug, Clone, Copy, PartialEq, Eq)]
105#[non_exhaustive]
106pub enum Color {
107 TrueColor {
108 r: u8,
109 g: u8,
110 b: u8,
111 },
112 Palette256(u8),
113 Palette16(AnsiColor),
114 NoColor,
118}
119
120impl Color {
121 #[must_use]
125 pub fn downgrade(self, cap: Capability) -> Color {
126 match (self, cap) {
127 (_, Capability::None) => Color::NoColor,
128 (Color::NoColor, _) => Color::NoColor,
129 (Color::TrueColor { r, g, b }, Capability::Palette256) => {
131 Color::Palette256(rgb_to_256(r, g, b))
132 }
133 (Color::TrueColor { r, g, b }, Capability::Palette16) => {
134 Color::Palette16(rgb_to_ansi16(r, g, b))
135 }
136 (Color::Palette256(n), Capability::Palette16) => {
138 Color::Palette16(palette256_to_ansi16(n))
139 }
140 (c, _) => c,
142 }
143 }
144}
145
146#[derive(Debug, Clone, Default, PartialEq, Eq)]
155#[non_exhaustive]
156pub struct Style {
157 pub role: Option<Role>,
158 pub fg: Option<Color>,
159 pub bold: bool,
160 pub italic: bool,
161 pub underline: bool,
162 pub dim: bool,
163 pub hyperlink: Option<String>,
164}
165
166impl Style {
167 #[must_use]
169 pub const fn role(role: Role) -> Self {
170 Self {
171 role: Some(role),
172 fg: None,
173 bold: false,
174 italic: false,
175 underline: false,
176 dim: false,
177 hyperlink: None,
178 }
179 }
180
181 #[must_use]
188 pub fn with_hyperlink(mut self, url: impl Into<String>) -> Self {
189 let url = url.into();
190 self.hyperlink = if url.is_empty() { None } else { Some(url) };
191 self
192 }
193}
194
195#[derive(Debug, Clone, PartialEq, Eq)]
207#[non_exhaustive]
208pub struct StyledRun {
209 pub(crate) text: String,
210 pub(crate) style: Style,
211}
212
213impl StyledRun {
214 #[must_use]
223 pub fn new(text: impl Into<String>, style: Style) -> Self {
224 Self {
225 text: text.into(),
226 style,
227 }
228 }
229
230 #[must_use]
233 pub fn text(&self) -> &str {
234 &self.text
235 }
236
237 #[must_use]
240 pub fn style(&self) -> &Style {
241 &self.style
242 }
243}
244
245#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
253pub enum Capability {
254 None,
255 Palette16,
256 Palette256,
257 TrueColor,
258}
259
260impl Capability {
261 #[must_use]
265 pub fn detect() -> Self {
266 if std::env::var_os("NO_COLOR").is_some() {
267 return Self::None;
268 }
269 Self::from_terminal()
270 }
271
272 #[must_use]
278 pub fn from_terminal() -> Self {
279 match supports_color::on(supports_color::Stream::Stdout) {
280 Some(c) if c.has_16m => Self::TrueColor,
281 Some(c) if c.has_256 => Self::Palette256,
282 Some(_) => Self::Palette16,
283 None => Self::None,
284 }
285 }
286
287 #[must_use]
297 pub fn from_env_vars(colorterm: Option<&str>, term: Option<&str>) -> Self {
298 if let Some(c) = colorterm {
299 if c.eq_ignore_ascii_case("truecolor") || c.eq_ignore_ascii_case("24bit") {
300 return Self::TrueColor;
301 }
302 }
303 match term.map(str::trim) {
304 None | Some("") => Self::None,
305 Some(t) if t.eq_ignore_ascii_case("dumb") => Self::None,
306 Some(t) if t.contains("256color") => Self::Palette256,
307 _ => Self::Palette16,
308 }
309 }
310
311 #[must_use]
319 pub fn force_from(tty: Self, env: Self) -> Self {
320 tty.max(env).max(Self::Palette16)
321 }
322}
323
324#[derive(Debug, Clone)]
331pub struct Theme {
332 name: &'static str,
333 colors: [Option<Color>; Role::COUNT],
334}
335
336impl Theme {
337 #[must_use]
341 pub fn name(&self) -> &'static str {
342 self.name
343 }
344
345 #[must_use]
350 pub(super) fn from_user_parts(
351 name: &'static str,
352 colors: [Option<Color>; Role::COUNT],
353 ) -> Self {
354 Self { name, colors }
355 }
356
357 #[must_use]
362 pub fn color(&self, role: Role) -> Color {
363 let mut current = role;
364 loop {
365 if let Some(c) = self.colors[current as usize] {
366 return c;
367 }
368 let next = current.fallback();
369 if next == current {
370 return Color::NoColor;
371 }
372 current = next;
373 }
374 }
375}
376
377const BUILTIN_THEMES: &[Theme] = &[
384 default::DEFAULT,
385 minimal::MINIMAL,
386 catppuccin::LATTE,
387 catppuccin::FRAPPE,
388 catppuccin::MACCHIATO,
389 catppuccin::MOCHA,
390 dracula::DRACULA,
391 nord::NORD,
392 gruvbox::GRUVBOX,
393 tokyo_night::TOKYO_NIGHT,
394 rose_pine::ROSE_PINE,
395];
396
397#[must_use]
400pub fn built_in(name: &str) -> Option<&'static Theme> {
401 BUILTIN_THEMES.iter().find(|t| t.name == name)
402}
403
404#[must_use]
407pub fn default_theme() -> &'static Theme {
408 built_in("default").expect("`default` theme is always compiled in")
409}
410
411pub fn builtin_names() -> impl Iterator<Item = &'static str> {
413 BUILTIN_THEMES.iter().map(|t| t.name)
414}
415
416#[must_use]
420pub fn sgr_open(style: &Style, theme: &Theme, cap: Capability) -> String {
421 let mut params = Vec::<u16>::with_capacity(4);
422 if style.bold {
423 params.push(1);
424 }
425 if style.dim {
426 params.push(2);
427 }
428 if style.italic {
429 params.push(3);
430 }
431 if style.underline {
432 params.push(4);
433 }
434 let fg = resolve_fg(style, theme, cap);
435 emit_fg_params(fg, &mut params);
436 if params.is_empty() {
437 return String::new();
438 }
439 let mut out = String::from("\x1b[");
440 for (i, p) in params.iter().enumerate() {
441 if i > 0 {
442 out.push(';');
443 }
444 let _ = write!(out, "{p}");
445 }
446 out.push('m');
447 out
448}
449
450#[must_use]
453pub const fn sgr_reset() -> &'static str {
454 "\x1b[0m"
455}
456
457fn resolve_fg(style: &Style, theme: &Theme, cap: Capability) -> Color {
461 let raw = style
462 .fg
463 .or_else(|| style.role.map(|r| theme.color(r)))
464 .unwrap_or(Color::NoColor);
465 raw.downgrade(cap)
466}
467
468fn emit_fg_params(color: Color, out: &mut Vec<u16>) {
471 match color {
472 Color::NoColor => {}
473 Color::Palette16(ansi) => {
474 let code = ansi_to_sgr_fg(ansi);
475 out.push(code);
476 }
477 Color::Palette256(n) => {
478 out.extend_from_slice(&[38, 5, u16::from(n)]);
479 }
480 Color::TrueColor { r, g, b } => {
481 out.extend_from_slice(&[38, 2, u16::from(r), u16::from(g), u16::from(b)]);
482 }
483 }
484}
485
486fn ansi_to_sgr_fg(c: AnsiColor) -> u16 {
487 let n = c as u8;
488 if n < 8 {
489 30 + u16::from(n)
490 } else {
491 90 + u16::from(n - 8)
493 }
494}
495
496fn rgb_to_256(r: u8, g: u8, b: u8) -> u8 {
502 if r == g && g == b {
503 if r < 8 {
504 return 16;
505 }
506 if r > 238 {
510 return 231;
511 }
512 return 232 + (r - 8) / 10;
513 }
514 let r6 = scale_to_cube(r);
515 let g6 = scale_to_cube(g);
516 let b6 = scale_to_cube(b);
517 16 + 36 * r6 + 6 * g6 + b6
518}
519
520fn scale_to_cube(channel: u8) -> u8 {
521 const LEVELS: [u8; 6] = [0, 95, 135, 175, 215, 255];
523 let mut best = 0u8;
524 let mut best_dist = u16::MAX;
525 for (i, &level) in LEVELS.iter().enumerate() {
526 let d = u16::from(channel.abs_diff(level));
527 if d < best_dist {
528 best_dist = d;
529 best = u8::try_from(i).expect("cube index fits in u8");
530 }
531 }
532 best
533}
534
535fn rgb_to_ansi16(r: u8, g: u8, b: u8) -> AnsiColor {
541 let max = r.max(g).max(b);
542 let min = r.min(g).min(b);
543 let bright = max >= 128;
544 if max - min < 32 {
546 return match max {
547 0..=42 => AnsiColor::Black,
548 43..=170 => {
549 if bright {
550 AnsiColor::BrightBlack
551 } else {
552 AnsiColor::White
553 }
554 }
555 _ => AnsiColor::BrightWhite,
556 };
557 }
558 let dominant_r = r >= g && r >= b;
560 let dominant_g = g >= r && g >= b;
561 let dominant_b = b >= r && b >= g;
562 let r_hi = r >= 128;
563 let g_hi = g >= 128;
564 let b_hi = b >= 128;
565 match (dominant_r, dominant_g, dominant_b, r_hi, g_hi, b_hi) {
566 (true, _, _, _, true, _) => {
567 if bright {
568 AnsiColor::BrightYellow
569 } else {
570 AnsiColor::Yellow
571 }
572 }
573 (true, _, _, _, _, true) => {
574 if bright {
575 AnsiColor::BrightMagenta
576 } else {
577 AnsiColor::Magenta
578 }
579 }
580 (_, true, _, _, _, true) => {
581 if bright {
582 AnsiColor::BrightCyan
583 } else {
584 AnsiColor::Cyan
585 }
586 }
587 (_, true, _, true, _, _) => {
588 if bright {
589 AnsiColor::BrightYellow
590 } else {
591 AnsiColor::Yellow
592 }
593 }
594 (true, _, _, _, _, _) => {
595 if bright {
596 AnsiColor::BrightRed
597 } else {
598 AnsiColor::Red
599 }
600 }
601 (_, true, _, _, _, _) => {
602 if bright {
603 AnsiColor::BrightGreen
604 } else {
605 AnsiColor::Green
606 }
607 }
608 (_, _, true, _, _, _) => {
609 if bright {
610 AnsiColor::BrightBlue
611 } else {
612 AnsiColor::Blue
613 }
614 }
615 _ => AnsiColor::White,
616 }
617}
618
619fn palette256_to_ansi16(n: u8) -> AnsiColor {
623 if n < 16 {
624 return match n {
625 0 => AnsiColor::Black,
626 1 => AnsiColor::Red,
627 2 => AnsiColor::Green,
628 3 => AnsiColor::Yellow,
629 4 => AnsiColor::Blue,
630 5 => AnsiColor::Magenta,
631 6 => AnsiColor::Cyan,
632 7 => AnsiColor::White,
633 8 => AnsiColor::BrightBlack,
634 9 => AnsiColor::BrightRed,
635 10 => AnsiColor::BrightGreen,
636 11 => AnsiColor::BrightYellow,
637 12 => AnsiColor::BrightBlue,
638 13 => AnsiColor::BrightMagenta,
639 14 => AnsiColor::BrightCyan,
640 _ => AnsiColor::BrightWhite,
641 };
642 }
643 if n >= 232 {
644 let step = n - 232;
646 return match step {
647 0..=7 => AnsiColor::Black,
648 8..=15 => AnsiColor::BrightBlack,
649 16..=19 => AnsiColor::White,
650 _ => AnsiColor::BrightWhite,
651 };
652 }
653 const LEVELS: [u8; 6] = [0, 95, 135, 175, 215, 255];
655 let c = n - 16;
656 let r6 = usize::from(c / 36);
657 let g6 = usize::from((c / 6) % 6);
658 let b6 = usize::from(c % 6);
659 rgb_to_ansi16(LEVELS[r6], LEVELS[g6], LEVELS[b6])
660}
661
662#[cfg(test)]
663mod tests {
664 use super::*;
665
666 #[test]
669 fn role_count_matches_enum_discriminant_range() {
670 assert_eq!(Role::Foreground as usize, 0);
671 assert_eq!(Role::Border as usize, Role::COUNT - 1);
672 }
673
674 #[test]
675 fn extended_role_fallbacks_match_spec_table() {
676 assert_eq!(Role::SuccessDim.fallback(), Role::Success);
677 assert_eq!(Role::WarningDim.fallback(), Role::Warning);
678 assert_eq!(Role::ErrorDim.fallback(), Role::Error);
679 assert_eq!(Role::PrimaryDim.fallback(), Role::Primary);
680 assert_eq!(Role::AccentDim.fallback(), Role::Accent);
681 assert_eq!(Role::Surface.fallback(), Role::Background);
682 assert_eq!(Role::Border.fallback(), Role::Muted);
683 }
684
685 #[test]
686 fn base_roles_fall_back_to_themselves() {
687 for role in [
688 Role::Foreground,
689 Role::Background,
690 Role::Muted,
691 Role::Primary,
692 Role::Accent,
693 Role::Success,
694 Role::Warning,
695 Role::Error,
696 Role::Info,
697 ] {
698 assert_eq!(role.fallback(), role);
699 }
700 }
701
702 #[test]
705 fn default_theme_maps_every_base_role() {
706 let t = built_in("default").expect("default exists");
707 for role in [
708 Role::Foreground,
709 Role::Muted,
710 Role::Primary,
711 Role::Accent,
712 Role::Success,
713 Role::Warning,
714 Role::Error,
715 Role::Info,
716 ] {
717 let c = t.color(role);
718 assert!(
719 !matches!(c, Color::NoColor),
720 "default theme left {role:?} as NoColor",
721 );
722 }
723 }
724
725 #[test]
726 fn default_theme_extended_roles_fall_back_to_base() {
727 let t = built_in("default").expect("default exists");
728 assert_eq!(t.color(Role::SuccessDim), t.color(Role::Success));
729 assert_eq!(t.color(Role::PrimaryDim), t.color(Role::Primary));
730 assert_eq!(t.color(Role::Border), t.color(Role::Muted));
731 }
732
733 #[test]
734 fn minimal_theme_returns_no_color_for_every_role() {
735 let t = built_in("minimal").expect("minimal exists");
736 for role in [
737 Role::Foreground,
738 Role::Primary,
739 Role::Warning,
740 Role::SuccessDim,
741 ] {
742 assert_eq!(t.color(role), Color::NoColor);
743 }
744 }
745
746 #[test]
747 fn unknown_theme_name_returns_none() {
748 assert!(built_in("nope").is_none());
749 }
750
751 #[test]
752 fn default_theme_always_available() {
753 assert_eq!(default_theme().name, "default");
754 }
755
756 #[test]
757 fn builtin_names_lists_default_and_minimal() {
758 let names: Vec<&str> = builtin_names().collect();
759 assert!(names.contains(&"default"));
760 assert!(names.contains(&"minimal"));
761 }
762
763 #[test]
764 fn builtin_names_lists_all_curated_presets() {
765 let names: Vec<&str> = builtin_names().collect();
772 for theme in ["dracula", "nord", "gruvbox", "tokyo-night", "rose-pine"] {
773 assert!(names.contains(&theme), "missing {theme} in builtin_names");
774 }
775 assert_eq!(
776 builtin_names().count(),
777 11,
778 "BUILTIN_THEMES count drift: default + minimal + 4 catppuccin + 5 curated = 11"
779 );
780 }
781
782 #[test]
783 fn every_curated_preset_maps_every_base_role() {
784 for name in ["dracula", "nord", "gruvbox", "tokyo-night", "rose-pine"] {
790 let t = built_in(name).expect(name);
791 for role in [
792 Role::Foreground,
793 Role::Background,
794 Role::Muted,
795 Role::Primary,
796 Role::Accent,
797 Role::Success,
798 Role::Warning,
799 Role::Error,
800 Role::Info,
801 ] {
802 assert!(
803 !matches!(t.color(role), Color::NoColor),
804 "{name} left {role:?} as NoColor"
805 );
806 }
807 }
808 }
809
810 #[test]
813 fn downgrade_strips_color_under_no_capability() {
814 assert_eq!(
815 Color::TrueColor { r: 255, g: 0, b: 0 }.downgrade(Capability::None),
816 Color::NoColor
817 );
818 assert_eq!(
819 Color::Palette16(AnsiColor::Red).downgrade(Capability::None),
820 Color::NoColor
821 );
822 }
823
824 #[test]
825 fn downgrade_preserves_matching_or_richer_capability() {
826 let c = Color::Palette16(AnsiColor::Green);
827 assert_eq!(c.downgrade(Capability::Palette16), c);
828 assert_eq!(c.downgrade(Capability::Palette256), c);
829 assert_eq!(c.downgrade(Capability::TrueColor), c);
830 }
831
832 #[test]
833 fn downgrade_truecolor_to_256_grayscale_uses_gray_ramp() {
834 let g = Color::TrueColor {
836 r: 128,
837 g: 128,
838 b: 128,
839 };
840 match g.downgrade(Capability::Palette256) {
841 Color::Palette256(n) => assert!((232..=255).contains(&n), "got {n}"),
842 other => panic!("expected Palette256, got {other:?}"),
843 }
844 }
845
846 #[test]
847 fn downgrade_truecolor_near_white_grayscale_saturates_without_overflow() {
848 for v in [239u8, 240, 248, 249, 255] {
852 let c = Color::TrueColor { r: v, g: v, b: v };
853 match c.downgrade(Capability::Palette256) {
854 Color::Palette256(n) => assert!(
855 (232..=255).contains(&n) || n == 231,
856 "r={v}: n={n} out of ramp/white range"
857 ),
858 other => panic!("expected Palette256, got {other:?}"),
859 }
860 }
861 }
862
863 #[test]
864 fn downgrade_truecolor_to_256_uses_cube_for_color() {
865 let red = Color::TrueColor { r: 255, g: 0, b: 0 };
867 assert_eq!(
868 red.downgrade(Capability::Palette256),
869 Color::Palette256(196)
870 );
871 let green = Color::TrueColor { r: 0, g: 255, b: 0 };
873 assert_eq!(
874 green.downgrade(Capability::Palette256),
875 Color::Palette256(46)
876 );
877 let blue = Color::TrueColor { r: 0, g: 0, b: 255 };
879 assert_eq!(
880 blue.downgrade(Capability::Palette256),
881 Color::Palette256(21)
882 );
883 }
884
885 #[test]
886 fn downgrade_palette256_cube_to_16_picks_a_color() {
887 match Color::Palette256(196).downgrade(Capability::Palette16) {
889 Color::Palette16(ac) => assert!(
890 matches!(ac, AnsiColor::Red | AnsiColor::BrightRed),
891 "got {ac:?}"
892 ),
893 other => panic!("expected Palette16, got {other:?}"),
894 }
895 }
896
897 #[test]
898 fn downgrade_palette256_low_16_passes_through_identity() {
899 assert_eq!(
901 Color::Palette256(1).downgrade(Capability::Palette16),
902 Color::Palette16(AnsiColor::Red),
903 );
904 assert_eq!(
905 Color::Palette256(15).downgrade(Capability::Palette16),
906 Color::Palette16(AnsiColor::BrightWhite),
907 );
908 }
909
910 #[test]
911 fn downgrade_palette256_grayscale_routes_to_blacks_or_whites() {
912 assert_eq!(
914 Color::Palette256(232).downgrade(Capability::Palette16),
915 Color::Palette16(AnsiColor::Black),
916 );
917 assert_eq!(
918 Color::Palette256(255).downgrade(Capability::Palette16),
919 Color::Palette16(AnsiColor::BrightWhite),
920 );
921 }
922
923 #[test]
924 fn downgrade_truecolor_red_to_16_picks_red_family() {
925 let red = Color::TrueColor {
926 r: 200,
927 g: 30,
928 b: 30,
929 };
930 match red.downgrade(Capability::Palette16) {
931 Color::Palette16(ac) => assert!(
932 matches!(ac, AnsiColor::Red | AnsiColor::BrightRed),
933 "got {ac:?}"
934 ),
935 other => panic!("expected Palette16, got {other:?}"),
936 }
937 }
938
939 #[test]
942 fn sgr_open_plain_style_emits_nothing() {
943 let s = Style::default();
944 let t = default_theme();
945 assert_eq!(sgr_open(&s, t, Capability::TrueColor), "");
946 }
947
948 #[test]
949 fn sgr_open_bold_only_emits_sgr1() {
950 let s = Style {
951 bold: true,
952 ..Style::default()
953 };
954 assert_eq!(sgr_open(&s, default_theme(), Capability::None), "\x1b[1m");
955 }
956
957 #[test]
958 fn sgr_open_role_under_palette16_emits_ansi_code() {
959 let s = Style::role(Role::Success);
960 let out = sgr_open(&s, default_theme(), Capability::Palette16);
961 assert_eq!(out, "\x1b[92m");
963 }
964
965 #[test]
966 fn sgr_open_role_under_no_capability_drops_color_keeps_decoration() {
967 let s = Style {
968 role: Some(Role::Error),
969 bold: true,
970 ..Style::default()
971 };
972 assert_eq!(sgr_open(&s, default_theme(), Capability::None), "\x1b[1m");
973 }
974
975 #[test]
976 fn sgr_open_explicit_fg_wins_over_role() {
977 let s = Style {
978 role: Some(Role::Warning),
979 fg: Some(Color::Palette16(AnsiColor::Blue)),
980 ..Style::default()
981 };
982 assert_eq!(
984 sgr_open(&s, default_theme(), Capability::Palette16),
985 "\x1b[34m"
986 );
987 }
988
989 #[test]
990 fn sgr_open_truecolor_fg_emits_38_2_sequence() {
991 let s = Style {
992 fg: Some(Color::TrueColor {
993 r: 12,
994 g: 34,
995 b: 56,
996 }),
997 ..Style::default()
998 };
999 assert_eq!(
1000 sgr_open(&s, default_theme(), Capability::TrueColor),
1001 "\x1b[38;2;12;34;56m"
1002 );
1003 }
1004
1005 #[test]
1006 fn sgr_open_combines_decorations_and_color() {
1007 let s = Style {
1008 role: Some(Role::Primary),
1009 bold: true,
1010 italic: true,
1011 ..Style::default()
1012 };
1013 assert_eq!(
1015 sgr_open(&s, default_theme(), Capability::Palette16),
1016 "\x1b[1;3;95m"
1017 );
1018 }
1019
1020 #[test]
1021 fn sgr_open_dim_only_emits_sgr2() {
1022 let s = Style {
1023 dim: true,
1024 ..Style::default()
1025 };
1026 assert_eq!(sgr_open(&s, default_theme(), Capability::None), "\x1b[2m");
1027 }
1028
1029 #[test]
1030 fn sgr_open_underline_only_emits_sgr4() {
1031 let s = Style {
1032 underline: true,
1033 ..Style::default()
1034 };
1035 assert_eq!(sgr_open(&s, default_theme(), Capability::None), "\x1b[4m");
1036 }
1037
1038 #[test]
1039 fn sgr_open_full_decoration_stack_keeps_order() {
1040 let s = Style {
1041 role: Some(Role::Primary),
1042 bold: true,
1043 dim: true,
1044 italic: true,
1045 underline: true,
1046 ..Style::default()
1047 };
1048 assert_eq!(
1050 sgr_open(&s, default_theme(), Capability::Palette16),
1051 "\x1b[1;2;3;4;95m"
1052 );
1053 }
1054
1055 #[test]
1056 fn sgr_reset_is_stable_and_short() {
1057 assert_eq!(sgr_reset(), "\x1b[0m");
1058 }
1059
1060 #[test]
1061 fn minimal_theme_emits_only_decorations() {
1062 let s = Style {
1063 role: Some(Role::Success),
1064 bold: true,
1065 ..Style::default()
1066 };
1067 let t = built_in("minimal").expect("minimal");
1068 assert_eq!(sgr_open(&s, t, Capability::TrueColor), "\x1b[1m");
1069 }
1070
1071 #[test]
1074 fn from_env_vars_prefers_colorterm_truecolor() {
1075 assert_eq!(
1076 Capability::from_env_vars(Some("truecolor"), Some("xterm")),
1077 Capability::TrueColor
1078 );
1079 assert_eq!(
1080 Capability::from_env_vars(Some("24bit"), Some("xterm")),
1081 Capability::TrueColor
1082 );
1083 assert_eq!(
1084 Capability::from_env_vars(Some("TRUECOLOR"), None),
1085 Capability::TrueColor
1086 );
1087 }
1088
1089 #[test]
1090 fn from_env_vars_falls_back_to_term_256color() {
1091 assert_eq!(
1092 Capability::from_env_vars(None, Some("xterm-256color")),
1093 Capability::Palette256
1094 );
1095 assert_eq!(
1096 Capability::from_env_vars(None, Some("tmux-256color")),
1097 Capability::Palette256
1098 );
1099 }
1100
1101 #[test]
1102 fn from_env_vars_unknown_term_is_palette16() {
1103 assert_eq!(
1104 Capability::from_env_vars(None, Some("xterm")),
1105 Capability::Palette16
1106 );
1107 assert_eq!(
1108 Capability::from_env_vars(None, Some("vt100")),
1109 Capability::Palette16
1110 );
1111 }
1112
1113 #[test]
1114 fn from_env_vars_dumb_or_missing_term_is_none() {
1115 assert_eq!(
1116 Capability::from_env_vars(None, Some("dumb")),
1117 Capability::None
1118 );
1119 assert_eq!(
1121 Capability::from_env_vars(None, Some("DUMB")),
1122 Capability::None
1123 );
1124 assert_eq!(
1125 Capability::from_env_vars(None, Some(" dumb ")),
1126 Capability::None
1127 );
1128 assert_eq!(Capability::from_env_vars(None, Some("")), Capability::None);
1129 assert_eq!(Capability::from_env_vars(None, None), Capability::None);
1130 }
1131
1132 #[test]
1133 fn from_env_vars_colorterm_truecolor_overrides_term_dumb() {
1134 assert_eq!(
1136 Capability::from_env_vars(Some("truecolor"), Some("dumb")),
1137 Capability::TrueColor
1138 );
1139 }
1140
1141 #[test]
1144 fn force_from_picks_max_of_both_inputs() {
1145 assert_eq!(
1146 Capability::force_from(Capability::Palette256, Capability::TrueColor),
1147 Capability::TrueColor
1148 );
1149 assert_eq!(
1150 Capability::force_from(Capability::TrueColor, Capability::Palette256),
1151 Capability::TrueColor
1152 );
1153 assert_eq!(
1154 Capability::force_from(Capability::Palette16, Capability::Palette256),
1155 Capability::Palette256
1156 );
1157 }
1158
1159 #[test]
1160 fn force_from_truecolor_from_either_side_wins() {
1161 assert_eq!(
1162 Capability::force_from(Capability::None, Capability::TrueColor),
1163 Capability::TrueColor
1164 );
1165 assert_eq!(
1166 Capability::force_from(Capability::TrueColor, Capability::None),
1167 Capability::TrueColor
1168 );
1169 }
1170
1171 #[test]
1172 fn force_from_floors_at_palette16_when_both_inputs_are_none() {
1173 assert_eq!(
1176 Capability::force_from(Capability::None, Capability::None),
1177 Capability::Palette16
1178 );
1179 }
1180
1181 #[test]
1182 fn force_from_floor_overrides_dumb_term_when_env_probe_returns_none() {
1183 let env = Capability::from_env_vars(None, Some("dumb"));
1186 assert_eq!(env, Capability::None);
1187 assert_eq!(
1188 Capability::force_from(Capability::None, env),
1189 Capability::Palette16
1190 );
1191 }
1192
1193 #[test]
1194 fn from_env_vars_colorterm_garbage_falls_through_to_term() {
1195 assert_eq!(
1198 Capability::from_env_vars(Some("yes"), Some("xterm-256color")),
1199 Capability::Palette256
1200 );
1201 }
1202}