Skip to main content

rusticity_term/ui/
summary.rs

1//! Content-sized, styled detail summaries using the shared field layout.
2use super::{
3    calculate_dynamic_height, labeled_field, render_fields_with_dynamic_columns, titled_block,
4};
5use ratatui::prelude::*;
6
7pub trait SummaryField {
8    fn summary_line(&self) -> Line<'_>;
9}
10impl SummaryField for Line<'_> {
11    fn summary_line(&self) -> Line<'_> {
12        self.clone()
13    }
14}
15impl SummaryField for (&str, String) {
16    fn summary_line(&self) -> Line<'_> {
17        labeled_field(self.0, self.1.clone())
18    }
19}
20
21pub struct Summary<'a> {
22    title: &'a str,
23    lines: Vec<Line<'a>>,
24}
25impl<'a> Summary<'a> {
26    pub fn new(title: &'a str, lines: Vec<Line<'a>>) -> Self {
27        Self { title, lines }
28    }
29    /// Outer height including exactly the block's two border rows.
30    pub fn height(&self, width: u16) -> u16 {
31        calculate_dynamic_height(&self.lines, width.saturating_sub(2)).saturating_add(2)
32    }
33    /// Fits content within the provided area and returns the area consumed.
34    pub fn render(self, frame: &mut Frame, area: Rect) -> Rect {
35        let area = Rect {
36            height: self.height(area.width).min(area.height),
37            ..area
38        };
39        self.render_fill(frame, area);
40        area
41    }
42    /// Explicit opt-in for panes that intentionally fill their available space.
43    pub fn render_fill(self, frame: &mut Frame, area: Rect) {
44        let block = titled_block(self.title);
45        let inner = block.inner(area);
46        frame.render_widget(block, area);
47        render_fields_with_dynamic_columns(frame, inner, self.lines);
48    }
49}
50
51pub fn render_summary<F: SummaryField>(
52    frame: &mut Frame,
53    area: Rect,
54    title: &str,
55    fields: &[F],
56) -> Rect {
57    Summary::new(
58        title,
59        fields.iter().map(SummaryField::summary_line).collect(),
60    )
61    .render(frame, area)
62}
63
64#[cfg(test)]
65mod tests {
66    use super::*;
67    use ratatui::{backend::TestBackend, Terminal};
68
69    #[test]
70    fn summary_fits_content_and_preserves_styles_and_unicode() {
71        for width in [20, 45, 180] {
72            let lines = vec![
73                Line::from(vec![
74                    Span::raw("State: "),
75                    Span::styled("✅ Available", Style::default().fg(Color::Green)),
76                ]),
77                Line::raw("Name: 資源"),
78                Line::raw("Owner: test"),
79            ];
80            let height = Summary::new("General", lines.clone()).height(width);
81            let mut terminal = Terminal::new(TestBackend::new(width, 20)).unwrap();
82            terminal
83                .draw(|frame| {
84                    let used = render_summary(frame, frame.area(), "General", &lines);
85                    assert_eq!(used.height, height);
86                })
87                .unwrap();
88            let buffer = terminal.backend().buffer();
89            assert_eq!(buffer[(0, height - 1)].symbol(), "╰");
90            assert_eq!(buffer[(0, height)].symbol(), " ");
91            assert!(buffer
92                .content()
93                .iter()
94                .any(|c| c.symbol().contains('✅') && c.fg == Color::Green));
95        }
96    }
97
98    #[test]
99    fn summary_accepts_plain_fields_and_clamps_tiny_areas() {
100        let mut terminal = Terminal::new(TestBackend::new(80, 10)).unwrap();
101        terminal
102            .draw(|frame| {
103                let fields = [("Name", "example".to_string())];
104                let used = render_summary(frame, Rect::new(3, 2, 40, 8), "Summary", &fields);
105                assert_eq!(used, Rect::new(3, 2, 40, 3));
106                for area in [Rect::new(0, 0, 0, 0), Rect::new(0, 0, 1, 1)] {
107                    let used = render_summary(frame, area, "Tiny", &fields);
108                    assert!(used.height <= area.height);
109                }
110            })
111            .unwrap();
112    }
113}