Skip to main content

urushi_cli/
summary.rs

1//! Titled, aligned result summaries.
2
3use urushi::{
4    Align, BlockStyle, Border, GridStyle, Length, Overflow, TextStyle, VerticalAlign, View,
5};
6
7use crate::CliRole;
8
9#[derive(Debug, Clone, PartialEq, Eq)]
10pub struct SummaryField {
11    label: String,
12    value: String,
13}
14
15impl SummaryField {
16    /// Creates one labelled field.
17    ///
18    /// `label` and `value` are plain text. Escape sequences and cursor movement in it break that contract:
19    /// debug builds panic, and release builds measure them as ordinary
20    /// characters and may split them when wrapping or truncating. Raw ANSI is not accepted as component text.
21    ///
22    /// Style the component through its [`CliTheme`](crate::CliTheme) rather
23    /// than by pre-rendering its content.
24    pub fn new(label: impl Into<String>, value: impl Into<String>) -> Self {
25        Self {
26            label: label.into(),
27            value: value.into(),
28        }
29    }
30
31    pub fn label(&self) -> &str {
32        &self.label
33    }
34    pub fn value(&self) -> &str {
35        &self.value
36    }
37}
38
39#[derive(Debug, Clone, PartialEq, Eq)]
40pub struct Summary {
41    title: String,
42    fields: Vec<SummaryField>,
43}
44
45impl Summary {
46    /// `title` is plain text: escape sequences and cursor movement in it break
47    /// that contract, and debug builds panic on them.
48    pub fn new(title: impl Into<String>) -> Self {
49        Self {
50            title: title.into(),
51            fields: Vec::new(),
52        }
53    }
54
55    /// Appends one labelled field, whose label and value are plain text.
56    #[must_use]
57    pub fn field(mut self, label: impl Into<String>, value: impl Into<String>) -> Self {
58        self.fields.push(SummaryField::new(label, value));
59        self
60    }
61
62    pub fn title(&self) -> &str {
63        &self.title
64    }
65    pub fn fields(&self) -> &[SummaryField] {
66        &self.fields
67    }
68}
69
70/// Presentation policy used to compose a [`Summary`] into a [`View`].
71#[derive(Debug, Clone, PartialEq, Eq)]
72pub struct SummaryPresentation {
73    muted: TextStyle,
74    accent: TextStyle,
75    body: TextStyle,
76}
77
78impl SummaryPresentation {
79    /// Creates the canonical summary presentation from its three text roles.
80    pub fn new(muted: TextStyle, accent: TextStyle, body: TextStyle) -> Self {
81        Self {
82            muted,
83            accent,
84            body,
85        }
86    }
87
88    /// Composes summary data into renderer-neutral layout primitives.
89    pub fn compose(&self, summary: &Summary) -> View {
90        let fields = View::grid(
91            GridStyle::new().columns([None, Some(Length::Cells(2)), None]),
92            summary.fields.iter().map(|field| {
93                [
94                    filled_text(&field.label, &self.muted),
95                    filled_text("  ", &self.muted),
96                    View::text(field.value.clone(), self.body.clone()),
97                ]
98            }),
99        );
100
101        View::column(
102            Align::Left,
103            [
104                View::text("│", self.muted.clone()),
105                View::row(
106                    VerticalAlign::Top,
107                    [
108                        View::text("◇", self.accent.clone()),
109                        View::text("  ", self.muted.clone()),
110                        View::text(summary.title.clone(), self.accent.clone()),
111                    ],
112                ),
113                View::block(
114                    BlockStyle::from_text_style(self.muted.clone())
115                        .border(Border {
116                            left: '│',
117                            ..Border::HIDDEN
118                        })
119                        .border_top(false)
120                        .border_right(false)
121                        .border_bottom(false)
122                        .border_text_style(self.muted.clone())
123                        .padding((0, 0, 0, 2)),
124                    fields,
125                ),
126            ],
127        )
128    }
129
130    pub(crate) fn set_style(&mut self, role: CliRole, style: TextStyle) {
131        match role {
132            CliRole::Muted => self.muted = style,
133            CliRole::Accent => self.accent = style,
134            CliRole::Body => self.body = style,
135            CliRole::Warning => {}
136        }
137    }
138}
139
140fn filled_text(text: impl Into<String>, style: &TextStyle) -> View {
141    View::block(
142        BlockStyle::from_text_style(style.clone())
143            .width(Length::fill(1))
144            .height(Length::fill(1))
145            .overflow(Overflow::clip()),
146        View::text(text, style.clone()),
147    )
148}
149
150#[cfg(test)]
151mod tests {
152    use super::*;
153    use urushi::{Available, Color, SemanticTokens, StyledGrapheme, Theme, measure, resolve};
154
155    use crate::test_support::{plain, style_at};
156
157    fn plain_at(view: &View, width: usize) -> String {
158        resolve(view, Available::columns(width))
159            .unwrap()
160            .rows()
161            .iter()
162            .map(|row| {
163                row.iter()
164                    .map(StyledGrapheme::symbol)
165                    .collect::<String>()
166                    .trim_end()
167                    .to_owned()
168            })
169            .collect::<Vec<_>>()
170            .join("\n")
171    }
172
173    fn theme() -> Theme {
174        Theme::from_tokens(SemanticTokens {
175            text: Color::Ansi(7),
176            text_muted: Color::Ansi(8),
177            background: Color::Ansi(0),
178            surface: Color::Ansi(0),
179            accent: Color::Ansi(6),
180            accent_text: Color::Ansi(0),
181            success: Color::Ansi(2),
182            warning: Color::Ansi(3),
183            error: Color::Ansi(1),
184            border: Color::Ansi(8),
185        })
186    }
187
188    #[test]
189    fn selected_width_reflows_cjk_values_without_recomposing() {
190        let theme = theme();
191        let view = crate::CliTheme::from_theme(&theme).summary(
192            &Summary::new("Result")
193                .field("Name", "日本語日本語")
194                .field("State", "ready"),
195        );
196
197        assert_eq!(
198            plain_at(&view, 22),
199            "│\n◇  Result\n│  Name   日本語日本語\n│  State  ready"
200        );
201        assert_eq!(
202            plain_at(&view, 12),
203            "│\n◇  Result\n│  Na  日本\n│      語日\n│      本語\n│  St  ready"
204        );
205    }
206
207    #[test]
208    fn aligns_long_cjk_labels_and_preserves_multiline_values() {
209        let theme = theme();
210        let view = crate::CliTheme::from_theme(&theme)
211            .summary(&Summary::new("結果").field("項目名称", "first\n日本語\n"));
212
213        assert_eq!(
214            plain_at(&view, 14),
215            "│\n◇  結果\n│  項目   firs\n│         t\n│         日本\n│         語"
216        );
217    }
218
219    #[test]
220    fn multiline_titles_and_labels_keep_later_fields_below_them() {
221        let theme = theme();
222        let view = crate::CliTheme::from_theme(&theme).summary(
223            &Summary::new("First title line\nSecond title line")
224                .field("First label line\nSecond label line", "value")
225                .field("Next", "field"),
226        );
227
228        assert_eq!(
229            plain_at(&view, 24),
230            "│\n◇  First title line\n   Second title line\n│  First label li  value\n│  Second label l\n│  Next            field"
231        );
232    }
233
234    #[test]
235    fn an_empty_summary_keeps_the_rail_and_title() {
236        let theme = theme();
237        let view = crate::CliTheme::from_theme(&theme).summary(&Summary::new("Done"));
238
239        assert_eq!(plain(&view), "│\n◇  Done");
240        assert_eq!(measure(&view).height(), 2);
241    }
242
243    #[test]
244    fn preserves_rail_title_label_gap_and_value_roles() {
245        let muted = TextStyle::new().background(Color::BLUE);
246        let accent = TextStyle::new().background(Color::GREEN);
247        let body = TextStyle::new().background(Color::RED);
248        let presentation = SummaryPresentation::new(muted.clone(), accent.clone(), body.clone());
249        let view = presentation.compose(
250            &Summary::new("Result")
251                .field("A", "first")
252                .field("Name", "second"),
253        );
254
255        assert_eq!(style_at(&view, 0, 0), muted);
256        assert_eq!(style_at(&view, 1, 0), accent);
257        assert_eq!(style_at(&view, 1, 1), muted);
258        assert_eq!(style_at(&view, 1, 3), accent);
259        assert_eq!(style_at(&view, 2, 0), muted);
260        assert_eq!(style_at(&view, 2, 3), muted);
261        assert_eq!(style_at(&view, 2, 4), muted);
262        assert_eq!(style_at(&view, 2, 7), muted);
263        assert_eq!(style_at(&view, 2, 9), body);
264    }
265
266    #[test]
267    fn presentation_and_composed_view_equality_include_styles_and_data() {
268        let theme = theme();
269        let summary = Summary::new("Result").field("Name", "urushi");
270        let cli_theme = crate::CliTheme::from_theme(&theme);
271        let base = cli_theme.summary_presentation().clone();
272        let changed = SummaryPresentation::new(
273            TextStyle::new().underlined(),
274            TextStyle::new(),
275            TextStyle::new(),
276        );
277
278        assert_eq!(base.compose(&summary), base.clone().compose(&summary));
279        assert_ne!(base.compose(&summary), changed.compose(&summary));
280        assert_ne!(
281            base.compose(&summary),
282            base.compose(&Summary::new("Other").field("Name", "urushi"))
283        );
284    }
285}