Skip to main content

datui_lib/widgets/ui/
surface.rs

1//! The one border. A modal, sidebar or overlay gets one rounded frame and one
2//! title; structure inside comes from alignment, section rules and the accent.
3
4use super::HintBar;
5use crate::render::context::RenderContext;
6use ratatui::buffer::Buffer;
7use ratatui::layout::Rect;
8use ratatui::style::Style;
9use ratatui::widgets::{Block, Borders, Clear, Widget};
10
11/// The frame every surface draws: rounded border, unpadded Title Case title,
12/// optionally a one-line chip footer on the last inner row.
13pub struct Surface<'a> {
14    title: &'a str,
15    footer: Option<&'a HintBar>,
16    /// The frame's own style when the default border slot is wrong for it:
17    /// an error surface carries the error border, a confirmation the active one.
18    border: Option<Style>,
19}
20
21impl<'a> Surface<'a> {
22    pub fn new(title: &'a str) -> Self {
23        Self {
24            title,
25            footer: None,
26            border: None,
27        }
28    }
29
30    pub fn footer(mut self, footer: &'a HintBar) -> Self {
31        self.footer = Some(footer);
32        self
33    }
34
35    pub fn border_style(mut self, style: Style) -> Self {
36        self.border = Some(style);
37        self
38    }
39
40    /// The content area [`Self::render`] returns for `area` with a footer, known
41    /// before drawing: a layout that decides the footer needs it first.
42    pub fn content_area(area: Rect) -> Rect {
43        // The frame's two rows, then the footer and the gap above it, as `render`.
44        let inside = area.height.saturating_sub(2);
45        let gap = u16::from(inside > 2);
46        Rect {
47            x: area.x + 2,
48            y: area.y + 1,
49            width: area.width.saturating_sub(4),
50            height: inside.saturating_sub(1 + gap),
51        }
52    }
53
54    /// Clear the area, draw the frame and footer, and return the content area:
55    /// the inside minus a one-column gutter each side, the footer row, and the blank
56    /// row that keeps the content's last line off the chips (#650).
57    pub fn render(&self, area: Rect, buf: &mut Buffer, ctx: &RenderContext) -> Rect {
58        Clear.render(area, buf);
59        let block = Block::default()
60            .borders(Borders::ALL)
61            .border_set(crate::glyphs::get().border)
62            .border_style(
63                self.border
64                    .unwrap_or_else(|| Style::default().fg(ctx.modal_border)),
65            )
66            .title(self.title)
67            .title_style(Style::reset());
68        let inner = block.inner(area);
69        block.render(area, buf);
70
71        let padded = Rect {
72            x: inner.x + 1,
73            y: inner.y,
74            width: inner.width.saturating_sub(2),
75            height: inner.height,
76        };
77        if let Some(footer) = self.footer {
78            if padded.height > 0 {
79                let footer_area = Rect {
80                    y: padded.y + padded.height - 1,
81                    height: 1,
82                    ..padded
83                };
84                footer.render_flush(footer_area, buf);
85            }
86            // The gap only where there is content left to keep apart.
87            let gap = u16::from(padded.height > 2);
88            return Rect {
89                height: padded.height.saturating_sub(1 + gap),
90                ..padded
91            };
92        }
93        padded
94    }
95}
96
97#[cfg(test)]
98mod tests {
99    use super::*;
100
101    fn render(width: u16, height: u16, with_footer: bool) -> (Vec<String>, Rect) {
102        let ctx = RenderContext::for_test();
103        let area = Rect::new(0, 0, width, height);
104        let mut buf = Buffer::empty(area);
105        let footer = HintBar::from_ctx(&ctx)
106            .screen(datui_cli::keys::Context::Export)
107            .key("Enter")
108            .key("Esc");
109        let surface = if with_footer {
110            Surface::new("Export Data").footer(&footer)
111        } else {
112            Surface::new("Export Data")
113        };
114        let content = surface.render(area, &mut buf, &ctx);
115        let rows = (0..height)
116            .map(|y| {
117                (0..width)
118                    .map(|x| buf[(x, y)].symbol().to_string())
119                    .collect::<String>()
120            })
121            .collect();
122        (rows, content)
123    }
124
125    #[test]
126    fn one_border_one_title_and_the_footer_inside_the_frame() {
127        let (rows, content) = render(40, 8, true);
128        assert!(rows[0].contains("Export Data"), "title on the frame");
129        // One border: the frame's corners and nothing box-drawn inside.
130        for row in &rows[1..7] {
131            assert!(
132                !row.contains('╭') && !row.contains('╰'),
133                "a second border inside the surface: {row:?}"
134            );
135        }
136        assert!(
137            rows[6].contains("Enter") && rows[6].contains("Export"),
138            "the footer is the last inner row: {:?}",
139            rows[6]
140        );
141        // Content sits above the footer, a blank row between, inside a one-column
142        // gutter.
143        assert_eq!(content, Rect::new(2, 1, 36, 4));
144        assert!(rows[5].trim_matches(['│', ' ']).is_empty(), "{:?}", rows[5]);
145        // Known before drawing, for a layout that sizes the footer from it.
146        assert_eq!(Surface::content_area(Rect::new(0, 0, 40, 8)), content);
147    }
148
149    #[test]
150    fn without_a_footer_the_content_runs_to_the_bottom() {
151        let (_, content) = render(40, 8, false);
152        assert_eq!(content, Rect::new(2, 1, 36, 6));
153    }
154
155    /// A degenerate area must not underflow or draw outside itself.
156    #[test]
157    fn a_tiny_area_stays_in_bounds() {
158        for (w, h) in [(0, 0), (1, 1), (2, 2), (3, 1)] {
159            let (_, content) = render(w, h, true);
160            assert!(content.width <= w && content.height <= h);
161        }
162    }
163}