Skip to main content

urushi_cli/
warning.rs

1//! Titled warning messages with a rail-aligned body.
2
3use urushi::{Align, BlockStyle, Border, TextStyle, VerticalAlign, View};
4
5use crate::CliRole;
6
7#[derive(Debug, Clone, PartialEq, Eq)]
8pub struct Warning {
9    title: String,
10    message: String,
11}
12
13impl Warning {
14    /// Creates a warning from a plain-text title and message.
15    pub fn new(title: impl Into<String>, message: impl Into<String>) -> Self {
16        Self {
17            title: title.into(),
18            message: message.into(),
19        }
20    }
21
22    pub fn title(&self) -> &str {
23        &self.title
24    }
25
26    pub fn message(&self) -> &str {
27        &self.message
28    }
29}
30
31/// Presentation policy used to compose a [`Warning`] into a [`View`].
32#[derive(Debug, Clone, PartialEq, Eq)]
33pub struct WarningPresentation {
34    muted: TextStyle,
35    warning: TextStyle,
36    body: TextStyle,
37}
38
39impl WarningPresentation {
40    /// Creates the canonical warning presentation from its three text roles.
41    pub fn new(muted: TextStyle, warning: TextStyle, body: TextStyle) -> Self {
42        Self {
43            muted,
44            warning,
45            body,
46        }
47    }
48
49    /// Composes warning data without binding it to an available width.
50    pub fn compose(&self, warning: &Warning) -> View {
51        let rail = Border {
52            left: '│',
53            ..Border::HIDDEN
54        };
55        let body = View::block(
56            BlockStyle::new()
57                .border(rail)
58                .border_top(false)
59                .border_right(false)
60                .border_bottom(false)
61                .border_text_style(self.muted.clone()),
62            View::row(
63                VerticalAlign::Top,
64                [
65                    View::block(
66                        BlockStyle::from_text_style(self.muted.clone()).width(2),
67                        View::text("  ", self.muted.clone()),
68                    ),
69                    View::text(warning.message.clone(), self.body.clone()),
70                ],
71            ),
72        );
73
74        View::column(
75            Align::Left,
76            [
77                View::text("│", self.muted.clone()),
78                View::row(
79                    VerticalAlign::Top,
80                    [
81                        View::text("▲", self.warning.clone()),
82                        View::text("  ", self.muted.clone()),
83                        View::text(warning.title.clone(), self.warning.clone()),
84                    ],
85                ),
86                body,
87            ],
88        )
89    }
90
91    pub(crate) fn set_style(&mut self, role: CliRole, style: TextStyle) {
92        match role {
93            CliRole::Muted => self.muted = style,
94            CliRole::Warning => self.warning = style,
95            CliRole::Body => self.body = style,
96            CliRole::Accent => {}
97        }
98    }
99}
100
101#[cfg(test)]
102mod tests {
103    use super::*;
104    use crate::test_support::{plain_at, style_at_width};
105    use urushi::{Available, Color, SemanticTokens, Theme, resolve};
106
107    fn cli_theme() -> crate::CliTheme {
108        crate::CliTheme::from_theme(&Theme::from_tokens(SemanticTokens {
109            text: Color::Ansi(7),
110            text_muted: Color::Ansi(8),
111            background: Color::Ansi(0),
112            surface: Color::Ansi(0),
113            accent: Color::Ansi(6),
114            accent_text: Color::Ansi(0),
115            success: Color::Ansi(2),
116            warning: Color::Ansi(3),
117            error: Color::Ansi(1),
118            border: Color::Ansi(8),
119        }))
120    }
121
122    #[test]
123    fn preserves_the_canonical_appearance() {
124        let view = cli_theme().warning(&Warning::new("Caution", "Careful"));
125
126        assert_eq!(plain_at(&view, 80), "│\n▲  Caution\n│  Careful");
127    }
128
129    #[test]
130    fn the_same_view_reflows_and_extends_its_border() {
131        let view = cli_theme().warning(&Warning::new(
132            "Caution",
133            "the quick brown fox jumps over the lazy dog",
134        ));
135
136        assert_eq!(
137            plain_at(&view, 24),
138            "│\n▲  Caution\n│  the quick brown fox\n│  jumps over the lazy\n│  dog"
139        );
140        assert_eq!(
141            plain_at(&view, 14),
142            "│\n▲  Caution\n│  the quick\n│  brown fox\n│  jumps over\n│  the lazy\n│  dog"
143        );
144    }
145
146    #[test]
147    fn border_title_and_body_keep_their_complete_roles() {
148        let muted = TextStyle::new().foreground(Color::BLUE).dim();
149        let warning = TextStyle::new().foreground(Color::YELLOW).bold();
150        let body = TextStyle::new().foreground(Color::WHITE);
151        let view = WarningPresentation::new(muted.clone(), warning.clone(), body.clone())
152            .compose(&Warning::new("Caution", "Careful"));
153        let resolved = resolve(&view, Available::columns(20)).unwrap();
154
155        assert_eq!(resolved.rows()[0][0].style(), &muted);
156        assert_eq!(resolved.rows()[1][0].style(), &warning);
157        assert_eq!(resolved.rows()[2][0].style(), &muted);
158        assert_eq!(resolved.rows()[2][1].style(), &muted);
159        assert_eq!(resolved.rows()[2][2].style(), &muted);
160        assert_eq!(style_at_width(&view, 20, 2, 3), body);
161    }
162}