Skip to main content

urushi_cli/
theme.rs

1//! CLI-specific theme roles and canonical presentations.
2
3use urushi::{ComponentRole, TextStyle, Theme, View};
4
5use crate::{Summary, SummaryPresentation, Warning, WarningPresentation};
6
7const CLI_ROLE_COUNT: usize = 4;
8
9/// A semantic text role in the canonical CLI presentation language.
10#[derive(Debug, Clone, Copy, PartialEq, Eq)]
11pub enum CliRole {
12    Body,
13    Muted,
14    Accent,
15    Warning,
16}
17
18impl CliRole {
19    const fn index(self) -> usize {
20        match self {
21            Self::Body => 0,
22            Self::Muted => 1,
23            Self::Accent => 2,
24            Self::Warning => 3,
25        }
26    }
27}
28
29/// Theme-derived styles and canonical presentations for CLI output.
30#[derive(Debug, Clone, PartialEq, Eq)]
31pub struct CliTheme {
32    styles: [TextStyle; CLI_ROLE_COUNT],
33    summary_presentation: SummaryPresentation,
34    warning_presentation: WarningPresentation,
35}
36
37impl CliTheme {
38    /// Derives the canonical CLI presentation language from a core theme.
39    pub fn from_theme(theme: &Theme) -> Self {
40        let styles = [
41            theme.text_style(ComponentRole::Body),
42            theme.text_style(ComponentRole::Muted),
43            theme.text_style(ComponentRole::Accent),
44            TextStyle::new().foreground(theme.tokens().warning),
45        ];
46        let summary_presentation = SummaryPresentation::new(
47            styles[CliRole::Muted.index()].clone(),
48            styles[CliRole::Accent.index()].clone(),
49            styles[CliRole::Body.index()].clone(),
50        );
51        let warning_presentation = WarningPresentation::new(
52            styles[CliRole::Muted.index()].clone(),
53            styles[CliRole::Warning.index()].clone(),
54            styles[CliRole::Body.index()].clone(),
55        );
56        Self {
57            styles,
58            summary_presentation,
59            warning_presentation,
60        }
61    }
62
63    /// Returns the style assigned to one CLI role.
64    pub fn get_style(&self, role: CliRole) -> &TextStyle {
65        &self.styles[role.index()]
66    }
67
68    /// Returns the canonical summary presentation.
69    pub fn summary_presentation(&self) -> &SummaryPresentation {
70        &self.summary_presentation
71    }
72
73    /// Returns the canonical warning presentation.
74    pub fn warning_presentation(&self) -> &WarningPresentation {
75        &self.warning_presentation
76    }
77
78    /// Composes a summary with the canonical presentation.
79    pub fn summary(&self, summary: &Summary) -> View {
80        self.summary_presentation.compose(summary)
81    }
82
83    /// Composes a warning with the canonical presentation.
84    pub fn warning(&self, warning: &Warning) -> View {
85        self.warning_presentation.compose(warning)
86    }
87
88    /// Replaces one CLI role and updates the canonical presentations that use it.
89    #[must_use]
90    pub fn style(mut self, role: CliRole, style: TextStyle) -> Self {
91        self.styles[role.index()] = style.clone();
92        self.summary_presentation.set_style(role, style.clone());
93        self.warning_presentation.set_style(role, style);
94        self
95    }
96}
97
98#[cfg(test)]
99mod tests {
100    use super::*;
101    use urushi::{Color, RenderSettings, SemanticTokens, StyledText, render_text};
102
103    fn render_style(style: &TextStyle, text: &str) -> String {
104        let settings = RenderSettings::all();
105        render_text(&StyledText::new(text, style.clone()), &settings)
106    }
107
108    fn theme() -> Theme {
109        Theme::from_tokens(SemanticTokens {
110            text: Color::Ansi(1),
111            text_muted: Color::Ansi(2),
112            background: Color::Ansi(3),
113            surface: Color::Ansi(4),
114            accent: Color::Ansi(5),
115            accent_text: Color::Ansi(6),
116            success: Color::Ansi(7),
117            warning: Color::Ansi(8),
118            error: Color::Ansi(9),
119            border: Color::Ansi(10),
120        })
121    }
122
123    #[test]
124    fn derives_cli_roles_from_the_core_theme() {
125        let cli = CliTheme::from_theme(&theme());
126
127        assert_eq!(
128            render_style(cli.get_style(CliRole::Body), "x"),
129            "\x1b[31mx\x1b[0m"
130        );
131        assert_eq!(
132            render_style(cli.get_style(CliRole::Muted), "x"),
133            "\x1b[2;32mx\x1b[0m"
134        );
135        assert_eq!(
136            render_style(cli.get_style(CliRole::Accent), "x"),
137            "\x1b[1;35mx\x1b[0m"
138        );
139        assert_eq!(
140            render_style(cli.get_style(CliRole::Warning), "x"),
141            "\x1b[90mx\x1b[0m"
142        );
143    }
144
145    #[test]
146    fn shortcuts_delegate_to_the_canonical_presentations() {
147        let theme = theme();
148        let cli = CliTheme::from_theme(&theme);
149        let summary = Summary::new("Done");
150        let warning = Warning::new("Caution", "Careful");
151
152        assert_eq!(
153            cli.summary(&summary),
154            cli.summary_presentation().compose(&summary)
155        );
156        assert_eq!(
157            cli.warning(&warning),
158            cli.warning_presentation().compose(&warning)
159        );
160    }
161
162    #[test]
163    fn role_styles_are_replaceable() {
164        let theme = theme();
165        let base = CliTheme::from_theme(&theme);
166        let changed_role = base
167            .clone()
168            .style(CliRole::Muted, TextStyle::new().underlined());
169        assert_eq!(
170            changed_role.get_style(CliRole::Muted),
171            &TextStyle::new().underlined()
172        );
173        assert_ne!(
174            changed_role.summary(&Summary::new("Done")),
175            base.summary(&Summary::new("Done"))
176        );
177    }
178}