1use rich::cells::char_cell_width;
4use rich::measure::Measurement;
5use rich::{Console, ConsoleOptions, Renderable, Segment, Style};
6
7use super::{
8 Charset, Line, cells, centre, entries_width, has_colour, lines_to_segments, theme_style,
9 truncate, user_style, wrap_entries,
10};
11
12#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
30pub enum Status {
31 Ok,
33 Warning,
35 Critical,
37 Unknown,
39}
40
41impl Status {
42 pub fn symbol(self, ascii: bool) -> char {
44 match (self, ascii) {
45 (Status::Ok, false) => '✓',
46 (Status::Ok, true) => '+',
47 (Status::Warning, _) => '!',
48 (Status::Critical, false) => '✗',
49 (Status::Critical, true) => 'x',
50 (Status::Unknown, _) => '?',
51 }
52 }
53
54 pub fn word(self) -> &'static str {
56 match self {
57 Status::Ok => "ok",
58 Status::Warning => "warning",
59 Status::Critical => "critical",
60 Status::Unknown => "unknown",
61 }
62 }
63
64 pub fn key(self) -> &'static str {
67 match self {
68 Status::Ok => "chart.ok",
69 Status::Warning => "chart.warning",
70 Status::Critical => "chart.critical",
71 Status::Unknown => "chart.unknown",
72 }
73 }
74
75 pub fn text(self, ascii: bool) -> String {
77 format!("{} {}", self.symbol(ascii), self.word())
78 }
79}
80
81#[derive(Clone, Debug, PartialEq, Eq, Hash)]
101pub struct State {
102 name: String,
103 symbol: char,
104 ascii: char,
105 style: String,
106}
107
108impl State {
109 pub fn new(
115 name: impl Into<String>,
116 symbol: char,
117 ascii: char,
118 style: impl Into<String>,
119 ) -> Self {
120 let ascii = if ascii.is_ascii_graphic() || ascii == ' ' {
121 ascii
122 } else {
123 '?'
124 };
125 let symbol = if char_cell_width(symbol) == 1 {
126 symbol
127 } else {
128 ascii
129 };
130 State {
131 name: name.into(),
132 symbol,
133 ascii,
134 style: style.into(),
135 }
136 }
137
138 pub fn pass() -> Self {
140 State::new("pass", '✓', '+', "chart.state.pass")
141 }
142
143 pub fn fail() -> Self {
145 State::new("fail", '✗', 'X', "chart.state.fail")
146 }
147
148 pub fn skip() -> Self {
150 State::new("skip", '○', '-', "chart.state.skip")
151 }
152
153 pub fn flaky() -> Self {
155 State::new("flaky", '≈', '~', "chart.state.flaky")
156 }
157
158 pub fn name(&self) -> &str {
160 &self.name
161 }
162
163 pub fn symbol(&self, ascii: bool) -> char {
165 if ascii { self.ascii } else { self.symbol }
166 }
167
168 pub fn style(&self) -> &str {
170 &self.style
171 }
172}
173
174#[derive(Clone, Copy, Debug, PartialEq)]
176struct Grid {
177 label: usize,
179 column: usize,
181 gap: usize,
183 shown: usize,
185 headers: bool,
187}
188
189#[derive(Clone, Debug, PartialEq)]
227#[must_use = "builder methods return a new value; it does nothing until it is rendered or used"]
228pub struct StatusMatrix {
229 columns: Vec<String>,
230 rows: Vec<(String, Vec<String>)>,
231 states: Vec<State>,
232 charset: Charset,
233 legend: bool,
234}
235
236impl Default for StatusMatrix {
237 fn default() -> Self {
238 Self::new()
239 }
240}
241
242impl StatusMatrix {
243 pub fn new() -> Self {
245 StatusMatrix {
246 columns: Vec::new(),
247 rows: Vec::new(),
248 states: vec![State::pass(), State::fail(), State::skip(), State::flaky()],
249 charset: Charset::Auto,
250 legend: true,
251 }
252 }
253
254 pub fn columns<S: Into<String>>(mut self, headers: impl IntoIterator<Item = S>) -> Self {
256 self.columns = headers.into_iter().map(Into::into).collect();
257 self
258 }
259
260 pub fn row<S: Into<String>>(
263 mut self,
264 label: impl Into<String>,
265 states: impl IntoIterator<Item = S>,
266 ) -> Self {
267 self.rows
268 .push((label.into(), states.into_iter().map(Into::into).collect()));
269 self
270 }
271
272 pub fn state(mut self, state: State) -> Self {
275 match self.states.iter_mut().find(|s| s.name == state.name) {
276 Some(existing) => *existing = state,
277 None => self.states.push(state),
278 }
279 self
280 }
281
282 pub fn legend(mut self, show: bool) -> Self {
284 self.legend = show;
285 self
286 }
287
288 pub fn charset(mut self, charset: Charset) -> Self {
292 self.charset = charset;
293 self
294 }
295
296 fn find(&self, name: &str) -> Option<&State> {
297 self.states.iter().find(|s| s.name == name)
298 }
299
300 pub fn counts(&self) -> Vec<(String, usize)> {
304 let mut out: Vec<(String, usize)> =
305 self.states.iter().map(|s| (s.name.clone(), 0)).collect();
306 for (_, cells) in &self.rows {
307 for name in cells {
308 match out.iter_mut().find(|(n, _)| n == name) {
309 Some((_, count)) => *count += 1,
310 None => out.push((name.clone(), 1)),
311 }
312 }
313 }
314 out.retain(|(_, count)| *count > 0);
315 out
316 }
317
318 fn column_count(&self) -> usize {
319 self.rows
320 .iter()
321 .map(|(_, c)| c.len())
322 .max()
323 .unwrap_or(0)
324 .max(self.columns.len())
325 }
326
327 fn header(&self, i: usize) -> &str {
328 self.columns.get(i).map(String::as_str).unwrap_or("")
329 }
330
331 fn label_width(&self) -> usize {
332 self.rows.iter().map(|(l, _)| cells(l)).max().unwrap_or(0)
333 }
334
335 fn header_width(&self) -> usize {
336 self.columns.iter().map(|h| cells(h)).max().unwrap_or(0)
337 }
338
339 fn natural_width(&self) -> usize {
340 let n = self.column_count();
341 let l = self.label_width();
342 let c = self.header_width().max(1);
343 l + usize::from(l > 0) + n * c + n.saturating_sub(1)
344 }
345
346 fn layout(&self, width: usize) -> Grid {
347 let n = self.column_count();
348 let l = self.label_width();
349 let h = self.header_width();
350 let natural = h.max(1);
351 let part = |l: usize| l + usize::from(l > 0);
352 let total = |l: usize, c: usize, gap: usize| part(l) + n * c + n.saturating_sub(1) * gap;
353 if total(l, natural, 1) <= width {
354 return Grid {
355 label: l,
356 column: natural,
357 gap: 1,
358 shown: n,
359 headers: h > 0,
360 };
361 }
362 let room = (width.saturating_sub(part(l)) + 1) / n.max(1);
364 if room >= 2 {
365 let column = room - 1;
366 return Grid {
367 label: l,
368 column,
369 gap: 1,
370 shown: n,
371 headers: h > 0 && (column >= 3 || h <= column),
372 };
373 }
374 let keep = l.min(3);
376 let headers = h == 1;
377 if let Some(label) = width.checked_sub(total(0, 1, 1)) {
378 let label = label.saturating_sub(1).min(l);
379 if label >= keep && (label > 0 || l == 0) {
380 return Grid {
381 label,
382 column: 1,
383 gap: 1,
384 shown: n,
385 headers,
386 };
387 }
388 }
389 let label = if width > part(keep) { keep } else { 0 };
391 let shown = n.min(width.saturating_sub(part(label)));
392 Grid {
393 label,
394 column: 1,
395 gap: 0,
396 shown,
397 headers,
398 }
399 }
400
401 fn lines(&self, console: &Console, options: &ConsoleOptions) -> Vec<Line> {
402 let width = options.max_width;
403 let ascii = self.charset.resolve(console, options, Charset::Blocks) == Charset::Ascii;
404 let colour = has_colour(console);
405 if self.column_count() == 0 {
406 let mut line = Line::new();
407 line.push(&truncate("no data", width, ascii), None);
408 return vec![line];
409 }
410 let grid = self.layout(width);
411 let label_style = colour.then(|| theme_style(console, "chart.label"));
412 let unknown = colour.then(|| theme_style(console, "chart.state.unknown"));
413 let state_style = |state: &State| colour.then(|| user_style(console, &state.style));
414 let mut out = Vec::new();
415 let lead = |line: &mut Line| {
416 if grid.label > 0 {
417 line.pad(grid.label + 1);
418 }
419 };
420 if grid.headers && grid.shown > 0 {
421 let mut line = Line::new();
422 lead(&mut line);
423 for i in 0..grid.shown {
424 if i > 0 {
425 line.pad(grid.gap);
426 }
427 centre(
428 &mut line,
429 self.header(i),
430 grid.column,
431 label_style.clone(),
432 ascii,
433 );
434 }
435 out.push(line);
436 }
437 for (label, states) in &self.rows {
438 let mut line = Line::new();
439 if grid.label > 0 {
440 let text = truncate(label, grid.label, ascii);
441 let pad = grid.label - cells(&text);
442 line.push(&text, label_style.clone());
443 line.pad(pad + 1);
444 }
445 for i in 0..grid.shown {
446 if i > 0 {
447 line.pad(grid.gap);
448 }
449 let (symbol, style) = match states.get(i) {
450 None => (' ', None),
451 Some(name) => match self.find(name) {
452 Some(state) => (state.symbol(ascii), state_style(state)),
453 None => ('?', unknown.clone()),
454 },
455 };
456 let pad = grid.column - 1;
457 line.pad(pad / 2);
458 line.push(&symbol.to_string(), style);
459 line.pad(pad - pad / 2);
460 }
461 out.push(line);
462 }
463 if self.legend {
464 out.extend(wrap_entries(
465 self.legend_entries(ascii, colour, console),
466 width,
467 ));
468 }
469 out
470 }
471
472 fn legend_entries(
473 &self,
474 ascii: bool,
475 colour: bool,
476 console: &Console,
477 ) -> Vec<Vec<(String, Option<Style>)>> {
478 let label_style = colour.then(|| theme_style(console, "chart.label"));
479 self.counts()
480 .into_iter()
481 .map(|(name, count)| {
482 let (symbol, style) = match self.find(&name) {
483 Some(state) => (
484 state.symbol(ascii),
485 colour.then(|| user_style(console, &state.style)),
486 ),
487 None => (
488 '?',
489 colour.then(|| theme_style(console, "chart.state.unknown")),
490 ),
491 };
492 let name = if ascii {
493 crate::fidelity::ascii_text(&name)
494 } else {
495 name
496 };
497 vec![
498 (symbol.to_string(), style),
499 (format!(" {name} {count}"), label_style.clone()),
500 ]
501 })
502 .collect()
503 }
504}
505
506impl crate::surface::Draw for StatusMatrix {
507 fn draw(
508 &self,
509 console: &Console,
510 options: &ConsoleOptions,
511 screen: &mut crate::surface::Screen,
512 rect: crate::surface::Rect,
513 ) {
514 super::draw_lines(
515 self.lines(console, options),
516 options.max_width,
517 options,
518 screen,
519 rect,
520 );
521 }
522}
523
524impl Renderable for StatusMatrix {
525 fn rich_render(&self, console: &Console, options: &ConsoleOptions) -> Vec<Segment> {
526 lines_to_segments(self.lines(console, options), options.max_width)
527 }
528
529 fn measure(&self, console: &Console, options: &ConsoleOptions) -> Measurement {
530 let n = self.column_count();
531 if n == 0 {
532 return Measurement::new(7, 7).with_maximum(options.max_width);
533 }
534 let ascii = self.charset.resolve(console, options, Charset::Blocks) == Charset::Ascii;
535 let legend = if self.legend {
536 entries_width(&self.legend_entries(ascii, false, console))
537 } else {
538 0
539 };
540 let max = self.natural_width().max(legend);
541 let min = (2 * n - 1).min(max);
542 Measurement::new(min, max)
543 .with_maximum(options.max_width)
544 .normalize()
545 }
546}
547
548impl crate::a11y::AccessibleText for StatusMatrix {
552 fn accessible_text(&self, _width: usize) -> String {
553 use super::describe::{capped, count, counted};
554 let columns = self.column_count();
555 if self.rows.is_empty() || columns == 0 {
556 return "Status matrix, no cells".to_string();
557 }
558 let counts = self.counts();
559 let tally: Vec<String> = counts
560 .iter()
561 .map(|(name, n)| format!("{} {name}", count(*n)))
562 .collect();
563 let mut out = vec![format!(
564 "Status matrix, {} by {}: {}",
565 counted(self.rows.len(), "row", "rows"),
566 counted(columns, "column", "columns"),
567 tally.join(", ")
568 )];
569 let header = |c: usize| match self.columns.get(c).filter(|h| !h.is_empty()) {
570 Some(h) => h.clone(),
571 None => format!("column {}", c + 1),
572 };
573 for (name, _) in counts.iter().filter(|(n, _)| n != "pass" && n != "skip") {
574 let cells: Vec<String> = self
575 .rows
576 .iter()
577 .flat_map(|(label, states)| {
578 states
579 .iter()
580 .enumerate()
581 .filter(|(_, s)| *s == name)
582 .map(move |(c, _)| match label.as_str() {
583 "" => header(c),
584 row => format!("{row} on {}", header(c)),
585 })
586 })
587 .collect();
588 out.push(format!("{name}: {}", capped(&cells, 10)));
589 }
590 out.join("\n")
591 }
592}
593
594#[cfg(test)]
595mod tests {
596 use super::*;
597 use crate::a11y::AccessibleText;
598
599 #[test]
600 fn accessible_text_counts_and_names_the_failures() {
601 let matrix = StatusMatrix::new()
602 .columns(["linux", "macos", "windows"])
603 .row("unit", ["pass", "pass", "fail"])
604 .row("lint", ["pass", "flaky", "skip"])
605 .row("docs", ["pass", "fail", "blocked"]);
606 assert_eq!(
607 matrix.accessible_text(80),
608 "Status matrix, 3 rows by 3 columns: 4 pass, 2 fail, 1 skip, 1 flaky, 1 blocked\n\
609 fail: unit on windows, docs on macos\n\
610 flaky: lint on macos\n\
611 blocked: docs on windows"
612 );
613 assert_eq!(
614 StatusMatrix::new().accessible_text(80),
615 "Status matrix, no cells"
616 );
617 }
618}