1use 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 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 pub fn new(title: impl Into<String>) -> Self {
49 Self {
50 title: title.into(),
51 fields: Vec::new(),
52 }
53 }
54
55 #[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#[derive(Debug, Clone, PartialEq, Eq)]
72pub struct SummaryPresentation {
73 muted: TextStyle,
74 accent: TextStyle,
75 body: TextStyle,
76}
77
78impl SummaryPresentation {
79 pub fn new(muted: TextStyle, accent: TextStyle, body: TextStyle) -> Self {
81 Self {
82 muted,
83 accent,
84 body,
85 }
86 }
87
88 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}