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<'a>>,
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<'a>) -> 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            .hint("Enter", "Apply")
107            .hint("Esc", "Cancel");
108        let surface = if with_footer {
109            Surface::new("Export Data").footer(&footer)
110        } else {
111            Surface::new("Export Data")
112        };
113        let content = surface.render(area, &mut buf, &ctx);
114        let rows = (0..height)
115            .map(|y| {
116                (0..width)
117                    .map(|x| buf[(x, y)].symbol().to_string())
118                    .collect::<String>()
119            })
120            .collect();
121        (rows, content)
122    }
123
124    #[test]
125    fn one_border_one_title_and_the_footer_inside_the_frame() {
126        let (rows, content) = render(40, 8, true);
127        assert!(rows[0].contains("Export Data"), "title on the frame");
128        // One border: the frame's corners and nothing box-drawn inside.
129        for row in &rows[1..7] {
130            assert!(
131                !row.contains('╭') && !row.contains('╰'),
132                "a second border inside the surface: {row:?}"
133            );
134        }
135        assert!(
136            rows[6].contains("Enter") && rows[6].contains("Apply"),
137            "the footer is the last inner row: {:?}",
138            rows[6]
139        );
140        // Content sits above the footer, a blank row between, inside a one-column
141        // gutter.
142        assert_eq!(content, Rect::new(2, 1, 36, 4));
143        assert!(rows[5].trim_matches(['│', ' ']).is_empty(), "{:?}", rows[5]);
144        // Known before drawing, for a layout that sizes the footer from it.
145        assert_eq!(Surface::content_area(Rect::new(0, 0, 40, 8)), content);
146    }
147
148    #[test]
149    fn without_a_footer_the_content_runs_to_the_bottom() {
150        let (_, content) = render(40, 8, false);
151        assert_eq!(content, Rect::new(2, 1, 36, 6));
152    }
153
154    /// A degenerate area must not underflow or draw outside itself.
155    #[test]
156    fn a_tiny_area_stays_in_bounds() {
157        for (w, h) in [(0, 0), (1, 1), (2, 2), (3, 1)] {
158            let (_, content) = render(w, h, true);
159            assert!(content.width <= w && content.height <= h);
160        }
161    }
162}