Skip to main content

saorsa_core/widget/
container.rs

1//! Container widget — a box with optional border and title.
2
3use crate::buffer::ScreenBuffer;
4use crate::cell::Cell;
5use crate::geometry::Rect;
6use crate::style::Style;
7
8use super::Widget;
9
10/// Border style for a container.
11#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
12pub enum BorderStyle {
13    /// No border.
14    #[default]
15    None,
16    /// Single line border: ┌┐└┘─│
17    Single,
18    /// Double line border: ╔╗╚╝═║
19    Double,
20    /// Rounded corners: ╭╮╰╯─│
21    Rounded,
22    /// Heavy/thick border: ┏┓┗┛━┃
23    Heavy,
24}
25
26/// A container widget with optional border, title, and padding.
27#[derive(Clone, Debug)]
28pub struct Container {
29    border: BorderStyle,
30    border_style: Style,
31    title: Option<String>,
32    title_style: Style,
33    padding: u16,
34}
35
36impl Container {
37    /// Create a new container with no border.
38    pub fn new() -> Self {
39        Self {
40            border: BorderStyle::None,
41            border_style: Style::default(),
42            title: None,
43            title_style: Style::default(),
44            padding: 0,
45        }
46    }
47
48    /// Set the border style.
49    #[must_use]
50    pub fn border(mut self, style: BorderStyle) -> Self {
51        self.border = style;
52        self
53    }
54
55    /// Set the border color/style.
56    #[must_use]
57    pub fn border_style(mut self, style: Style) -> Self {
58        self.border_style = style;
59        self
60    }
61
62    /// Set the title displayed in the top border.
63    #[must_use]
64    pub fn title(mut self, title: impl Into<String>) -> Self {
65        self.title = Some(title.into());
66        self
67    }
68
69    /// Set the title style.
70    #[must_use]
71    pub fn title_style(mut self, style: Style) -> Self {
72        self.title_style = style;
73        self
74    }
75
76    /// Set inner padding (cells on each side).
77    #[must_use]
78    pub fn padding(mut self, padding: u16) -> Self {
79        self.padding = padding;
80        self
81    }
82
83    /// Calculate the inner area (after border and padding).
84    pub fn inner_area(&self, area: Rect) -> Rect {
85        let border_offset = if self.border != BorderStyle::None {
86            1
87        } else {
88            0
89        };
90        let total_offset = border_offset + self.padding;
91
92        if area.size.width <= total_offset * 2 || area.size.height <= total_offset * 2 {
93            return Rect::new(
94                area.position.x + total_offset,
95                area.position.y + total_offset,
96                0,
97                0,
98            );
99        }
100
101        Rect::new(
102            area.position.x + total_offset,
103            area.position.y + total_offset,
104            area.size.width - total_offset * 2,
105            area.size.height - total_offset * 2,
106        )
107    }
108}
109
110impl Default for Container {
111    fn default() -> Self {
112        Self::new()
113    }
114}
115
116impl Widget for Container {
117    fn render(&self, area: Rect, buf: &mut ScreenBuffer) {
118        if area.size.width < 2 || area.size.height < 2 {
119            return;
120        }
121
122        let Some((tl, tr, bl, br, h, v)) = self.border.chars() else {
123            return; // No border to draw
124        };
125
126        let right = area.position.x + area.size.width - 1;
127        let bottom = area.position.y + area.size.height - 1;
128
129        // Corners
130        buf.set(
131            area.position.x,
132            area.position.y,
133            Cell::new(tl, self.border_style.clone()),
134        );
135        buf.set(
136            right,
137            area.position.y,
138            Cell::new(tr, self.border_style.clone()),
139        );
140        buf.set(
141            area.position.x,
142            bottom,
143            Cell::new(bl, self.border_style.clone()),
144        );
145        buf.set(right, bottom, Cell::new(br, self.border_style.clone()));
146
147        // Top and bottom edges
148        for x in (area.position.x + 1)..right {
149            buf.set(x, area.position.y, Cell::new(h, self.border_style.clone()));
150            buf.set(x, bottom, Cell::new(h, self.border_style.clone()));
151        }
152
153        // Left and right edges
154        for y in (area.position.y + 1)..bottom {
155            buf.set(area.position.x, y, Cell::new(v, self.border_style.clone()));
156            buf.set(right, y, Cell::new(v, self.border_style.clone()));
157        }
158
159        // Title (rendered in the top border)
160        if let Some(ref title) = self.title {
161            let max_title_width = (area.size.width.saturating_sub(4)) as usize; // leave space for corners + padding
162            let display_title = if title.len() > max_title_width {
163                &title[..max_title_width]
164            } else {
165                title.as_str()
166            };
167
168            let start_x = area.position.x + 2; // after corner + space
169            for (i, ch) in display_title.chars().enumerate() {
170                let x = start_x + i as u16;
171                if x < right {
172                    buf.set(
173                        x,
174                        area.position.y,
175                        Cell::new(ch.to_string(), self.title_style.clone()),
176                    );
177                }
178            }
179        }
180    }
181}
182
183#[cfg(test)]
184mod tests {
185    use super::*;
186    use crate::color::{Color, NamedColor};
187    use crate::geometry::Size;
188
189    #[test]
190    fn single_border_corners() {
191        let container = Container::new().border(BorderStyle::Single);
192        let mut buf = ScreenBuffer::new(Size::new(10, 5));
193        container.render(Rect::new(0, 0, 10, 5), &mut buf);
194
195        assert_eq!(buf.get(0, 0).map(|c| c.grapheme.as_str()), Some("┌"));
196        assert_eq!(buf.get(9, 0).map(|c| c.grapheme.as_str()), Some("┐"));
197        assert_eq!(buf.get(0, 4).map(|c| c.grapheme.as_str()), Some("└"));
198        assert_eq!(buf.get(9, 4).map(|c| c.grapheme.as_str()), Some("┘"));
199    }
200
201    #[test]
202    fn single_border_edges() {
203        let container = Container::new().border(BorderStyle::Single);
204        let mut buf = ScreenBuffer::new(Size::new(10, 5));
205        container.render(Rect::new(0, 0, 10, 5), &mut buf);
206
207        // Top edge
208        assert_eq!(buf.get(1, 0).map(|c| c.grapheme.as_str()), Some("─"));
209        // Bottom edge
210        assert_eq!(buf.get(1, 4).map(|c| c.grapheme.as_str()), Some("─"));
211        // Left edge
212        assert_eq!(buf.get(0, 1).map(|c| c.grapheme.as_str()), Some("│"));
213        // Right edge
214        assert_eq!(buf.get(9, 1).map(|c| c.grapheme.as_str()), Some("│"));
215    }
216
217    #[test]
218    fn double_border() {
219        let container = Container::new().border(BorderStyle::Double);
220        let mut buf = ScreenBuffer::new(Size::new(10, 5));
221        container.render(Rect::new(0, 0, 10, 5), &mut buf);
222
223        assert_eq!(buf.get(0, 0).map(|c| c.grapheme.as_str()), Some("╔"));
224        assert_eq!(buf.get(9, 0).map(|c| c.grapheme.as_str()), Some("╗"));
225    }
226
227    #[test]
228    fn rounded_border() {
229        let container = Container::new().border(BorderStyle::Rounded);
230        let mut buf = ScreenBuffer::new(Size::new(10, 5));
231        container.render(Rect::new(0, 0, 10, 5), &mut buf);
232
233        assert_eq!(buf.get(0, 0).map(|c| c.grapheme.as_str()), Some("╭"));
234        assert_eq!(buf.get(9, 0).map(|c| c.grapheme.as_str()), Some("╮"));
235    }
236
237    #[test]
238    fn heavy_border() {
239        let container = Container::new().border(BorderStyle::Heavy);
240        let mut buf = ScreenBuffer::new(Size::new(10, 5));
241        container.render(Rect::new(0, 0, 10, 5), &mut buf);
242
243        assert_eq!(buf.get(0, 0).map(|c| c.grapheme.as_str()), Some("┏"));
244        assert_eq!(buf.get(1, 0).map(|c| c.grapheme.as_str()), Some("━"));
245    }
246
247    #[test]
248    fn border_with_title() {
249        let container = Container::new().border(BorderStyle::Single).title("Test");
250        let mut buf = ScreenBuffer::new(Size::new(20, 5));
251        container.render(Rect::new(0, 0, 20, 5), &mut buf);
252
253        assert_eq!(buf.get(2, 0).map(|c| c.grapheme.as_str()), Some("T"));
254        assert_eq!(buf.get(3, 0).map(|c| c.grapheme.as_str()), Some("e"));
255        assert_eq!(buf.get(4, 0).map(|c| c.grapheme.as_str()), Some("s"));
256        assert_eq!(buf.get(5, 0).map(|c| c.grapheme.as_str()), Some("t"));
257    }
258
259    #[test]
260    fn border_with_style() {
261        let style = Style::new().fg(Color::Named(NamedColor::Cyan));
262        let container = Container::new()
263            .border(BorderStyle::Single)
264            .border_style(style.clone());
265        let mut buf = ScreenBuffer::new(Size::new(10, 5));
266        container.render(Rect::new(0, 0, 10, 5), &mut buf);
267
268        assert_eq!(buf.get(0, 0).map(|c| &c.style), Some(&style));
269    }
270
271    #[test]
272    fn inner_area_with_border() {
273        let container = Container::new().border(BorderStyle::Single);
274        let inner = container.inner_area(Rect::new(0, 0, 20, 10));
275        assert_eq!(inner, Rect::new(1, 1, 18, 8));
276    }
277
278    #[test]
279    fn inner_area_with_border_and_padding() {
280        let container = Container::new().border(BorderStyle::Single).padding(1);
281        let inner = container.inner_area(Rect::new(0, 0, 20, 10));
282        assert_eq!(inner, Rect::new(2, 2, 16, 6));
283    }
284
285    #[test]
286    fn inner_area_no_border() {
287        let container = Container::new();
288        let inner = container.inner_area(Rect::new(0, 0, 20, 10));
289        assert_eq!(inner, Rect::new(0, 0, 20, 10));
290    }
291
292    #[test]
293    fn no_border_renders_nothing() {
294        let container = Container::new();
295        let mut buf = ScreenBuffer::new(Size::new(10, 5));
296        container.render(Rect::new(0, 0, 10, 5), &mut buf);
297        // All cells should still be blank
298        assert!(buf.get(0, 0).is_some_and(|c| c.is_blank()));
299    }
300}