datui_lib/widgets/ui/
surface.rs1use 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
11pub struct Surface<'a> {
14 title: &'a str,
15 footer: Option<&'a HintBar>,
16 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 pub fn content_area(area: Rect) -> Rect {
43 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 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 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 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 assert_eq!(content, Rect::new(2, 1, 36, 4));
144 assert!(rows[5].trim_matches(['│', ' ']).is_empty(), "{:?}", rows[5]);
145 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 #[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}