Skip to main content

saorsa_core/widget/
border.rs

1//! Border rendering utilities for widgets.
2//!
3//! Provides shared implementations for border rendering, inner area calculation,
4//! and border character selection.
5
6use crate::buffer::ScreenBuffer;
7use crate::cell::Cell;
8use crate::geometry::Rect;
9use crate::style::Style;
10
11use super::BorderStyle;
12
13/// Border character set: (top-left, top-right, bottom-left, bottom-right, horizontal, vertical)
14pub type BorderChars = (
15    &'static str,
16    &'static str,
17    &'static str,
18    &'static str,
19    &'static str,
20    &'static str,
21);
22
23impl BorderStyle {
24    /// Get the Unicode box-drawing characters for this border style.
25    ///
26    /// Returns `None` for `BorderStyle::None`.
27    pub fn chars(self) -> Option<BorderChars> {
28        match self {
29            BorderStyle::None => None,
30            BorderStyle::Single => Some((
31                "\u{250c}", "\u{2510}", "\u{2514}", "\u{2518}", "\u{2500}", "\u{2502}",
32            )),
33            BorderStyle::Double => Some((
34                "\u{2554}", "\u{2557}", "\u{255a}", "\u{255d}", "\u{2550}", "\u{2551}",
35            )),
36            BorderStyle::Rounded => Some((
37                "\u{256d}", "\u{256e}", "\u{2570}", "\u{256f}", "\u{2500}", "\u{2502}",
38            )),
39            BorderStyle::Heavy => Some((
40                "\u{250f}", "\u{2513}", "\u{2517}", "\u{251b}", "\u{2501}", "\u{2503}",
41            )),
42        }
43    }
44}
45
46/// Render a border into the screen buffer.
47///
48/// If `border_style` is `BorderStyle::None`, this function does nothing.
49pub fn render_border(
50    area: Rect,
51    border_style: BorderStyle,
52    cell_style: Style,
53    buf: &mut ScreenBuffer,
54) {
55    let Some((tl, tr, bl, br, h, v)) = border_style.chars() else {
56        return;
57    };
58
59    let x1 = area.position.x;
60    let y1 = area.position.y;
61    let w = area.size.width;
62    let h_val = area.size.height;
63
64    if w == 0 || h_val == 0 {
65        return;
66    }
67
68    let x2 = x1.saturating_add(w.saturating_sub(1));
69    let y2 = y1.saturating_add(h_val.saturating_sub(1));
70
71    // Corners
72    buf.set(x1, y1, Cell::new(tl, cell_style.clone()));
73    buf.set(x2, y1, Cell::new(tr, cell_style.clone()));
74    buf.set(x1, y2, Cell::new(bl, cell_style.clone()));
75    buf.set(x2, y2, Cell::new(br, cell_style.clone()));
76
77    // Top and bottom edges
78    for x in (x1 + 1)..x2 {
79        buf.set(x, y1, Cell::new(h, cell_style.clone()));
80        buf.set(x, y2, Cell::new(h, cell_style.clone()));
81    }
82
83    // Left and right edges
84    for y in (y1 + 1)..y2 {
85        buf.set(x1, y, Cell::new(v, cell_style.clone()));
86        buf.set(x2, y, Cell::new(v, cell_style.clone()));
87    }
88}
89
90/// Calculate the inner area after accounting for a border.
91///
92/// If `border_style` is `BorderStyle::None`, returns the original area.
93/// Otherwise, shrinks the area by 1 cell on each side.
94///
95/// Returns a zero-sized rectangle if the area is too small for a border.
96pub fn inner_area(area: Rect, border_style: BorderStyle) -> Rect {
97    match border_style {
98        BorderStyle::None => area,
99        _ => {
100            if area.size.width < 2 || area.size.height < 2 {
101                return Rect::new(area.position.x, area.position.y, 0, 0);
102            }
103            Rect::new(
104                area.position.x + 1,
105                area.position.y + 1,
106                area.size.width.saturating_sub(2),
107                area.size.height.saturating_sub(2),
108            )
109        }
110    }
111}
112
113#[cfg(test)]
114mod tests {
115    use super::*;
116    use crate::geometry::Size;
117
118    #[test]
119    fn border_style_chars_single() {
120        let chars = BorderStyle::Single.chars();
121        assert!(chars.is_some());
122        match chars {
123            Some((tl, tr, bl, br, h, v)) => {
124                assert_eq!(tl, "\u{250c}");
125                assert_eq!(tr, "\u{2510}");
126                assert_eq!(bl, "\u{2514}");
127                assert_eq!(br, "\u{2518}");
128                assert_eq!(h, "\u{2500}");
129                assert_eq!(v, "\u{2502}");
130            }
131            None => unreachable!("should have border chars"),
132        }
133    }
134
135    #[test]
136    fn border_style_chars_none() {
137        assert!(BorderStyle::None.chars().is_none());
138    }
139
140    #[test]
141    fn inner_area_no_border() {
142        let area = Rect::new(5, 5, 20, 10);
143        let inner = inner_area(area, BorderStyle::None);
144        assert_eq!(inner, area);
145    }
146
147    #[test]
148    fn inner_area_with_border() {
149        let area = Rect::new(5, 5, 20, 10);
150        let inner = inner_area(area, BorderStyle::Single);
151        assert_eq!(inner.position.x, 6);
152        assert_eq!(inner.position.y, 6);
153        assert_eq!(inner.size.width, 18);
154        assert_eq!(inner.size.height, 8);
155    }
156
157    #[test]
158    fn inner_area_too_small() {
159        let area = Rect::new(0, 0, 1, 1);
160        let inner = inner_area(area, BorderStyle::Single);
161        assert_eq!(inner.size.width, 0);
162        assert_eq!(inner.size.height, 0);
163    }
164
165    #[test]
166    #[allow(clippy::unwrap_used)]
167    fn render_border_single() {
168        let mut buf = ScreenBuffer::new(Size::new(10, 5));
169        let area = Rect::new(0, 0, 10, 5);
170        let style = Style::default();
171
172        render_border(area, BorderStyle::Single, style, &mut buf);
173
174        // Check corners
175        assert_eq!(buf.get(0, 0).unwrap().grapheme, "\u{250c}");
176        assert_eq!(buf.get(9, 0).unwrap().grapheme, "\u{2510}");
177        assert_eq!(buf.get(0, 4).unwrap().grapheme, "\u{2514}");
178        assert_eq!(buf.get(9, 4).unwrap().grapheme, "\u{2518}");
179
180        // Check edges
181        assert_eq!(buf.get(1, 0).unwrap().grapheme, "\u{2500}");
182        assert_eq!(buf.get(0, 1).unwrap().grapheme, "\u{2502}");
183    }
184
185    #[test]
186    #[allow(clippy::unwrap_used)]
187    fn render_border_none_does_nothing() {
188        let mut buf = ScreenBuffer::new(Size::new(10, 5));
189        let area = Rect::new(0, 0, 10, 5);
190        let style = Style::default();
191
192        render_border(area, BorderStyle::None, style, &mut buf);
193
194        // All cells should remain blank (default)
195        assert_eq!(buf.get(0, 0).unwrap().grapheme, " ");
196    }
197}