Skip to main content

photon_ui/layout/
border.rs

1//! Border drawing primitives for terminal UIs.
2//!
3//! Provides [`Border`] — a set of characters for drawing rectangular
4//! outlines — and [`draw_border`] for rendering them into a `Rendered`
5//! buffer with ANSI styling.
6
7use crate::{
8    layout::Rect,
9    renderer::Rendered,
10    theme::{
11        ColorMode,
12        Style,
13    },
14};
15
16/// Characters used to draw a rectangular border.
17#[derive(Clone, Copy, Debug, PartialEq, Eq)]
18pub struct Border {
19    /// Character drawn on the left edge.
20    pub left: char,
21    /// Character drawn on the right edge.
22    pub right: char,
23    /// Character drawn on the top edge.
24    pub top: char,
25    /// Character drawn on the bottom edge.
26    pub bottom: char,
27    /// Character drawn at the top-left corner.
28    pub top_left: char,
29    /// Character drawn at the top-right corner.
30    pub top_right: char,
31    /// Character drawn at the bottom-left corner.
32    pub bottom_left: char,
33    /// Character drawn at the bottom-right corner.
34    pub bottom_right: char,
35}
36
37impl Border {
38    /// Double-line box-drawing border (╔═╗║║╚═╝).
39    pub const DOUBLE: Self = Self {
40        left: '║',
41        right: '║',
42        top: '═',
43        bottom: '═',
44        top_left: '╔',
45        top_right: '╗',
46        bottom_left: '╚',
47        bottom_right: '╝',
48    };
49    /// A left-only border (▐) — useful for callouts.
50    pub const LEFT: Self = Self {
51        left: '▐',
52        right: ' ',
53        top: ' ',
54        bottom: ' ',
55        top_left: '▐',
56        top_right: ' ',
57        bottom_left: '▐',
58        bottom_right: ' ',
59    };
60    /// No border — useful as a default.
61    pub const NONE: Self = Self {
62        left: ' ',
63        right: ' ',
64        top: ' ',
65        bottom: ' ',
66        top_left: ' ',
67        top_right: ' ',
68        bottom_left: ' ',
69        bottom_right: ' ',
70    };
71    /// Rounded box-drawing border (╭─╮││╰─╯).
72    pub const ROUNDED: Self = Self {
73        left: '│',
74        right: '│',
75        top: '─',
76        bottom: '─',
77        top_left: '╭',
78        top_right: '╮',
79        bottom_left: '╰',
80        bottom_right: '╯',
81    };
82    /// Thick/heavy box-drawing border (┏━┓┃┃┗━┛).
83    pub const THICK: Self = Self {
84        left: '┃',
85        right: '┃',
86        top: '━',
87        bottom: '━',
88        top_left: '┏',
89        top_right: '┓',
90        bottom_left: '┗',
91        bottom_right: '┛',
92    };
93    /// Single-line box-drawing border (┌─┐││└─┘).
94    pub const THIN: Self = Self {
95        left: '│',
96        right: '│',
97        top: '─',
98        bottom: '─',
99        top_left: '┌',
100        top_right: '┐',
101        bottom_left: '└',
102        bottom_right: '┘',
103    };
104
105    /// How many columns the border consumes on each axis.
106    ///
107    /// For a full border this is (2, 2); for a left-only border it's (1, 0).
108    pub fn size(&self) -> (u16, u16) {
109        let h = if self.left != ' ' || self.right != ' ' {
110            1
111        } else {
112            0
113        };
114        let v = if self.top != ' ' || self.bottom != ' ' {
115            1
116        } else {
117            0
118        };
119        (h, v)
120    }
121
122    /// The inner rect after removing border padding.
123    pub fn inner(&self, rect: Rect) -> Rect {
124        let (h, v) = self.size();
125        Rect::new(
126            rect.x + h,
127            rect.y + v,
128            rect.width.saturating_sub(h * 2),
129            rect.height.saturating_sub(v * 2),
130        )
131    }
132}
133
134impl Default for Border {
135    fn default() -> Self {
136        Self::THIN
137    }
138}
139
140/// Draw a border into `target` within the given `rect`.
141///
142/// The border characters are styled with `style`. Existing content in
143/// `target` is preserved; border characters overwrite the edges of the
144/// rect.
145///
146/// # Panics
147///
148/// Panics if `rect` extends beyond `target`'s current dimensions.
149pub fn draw_border(target: &mut Rendered, rect: Rect, border: &Border, style: &Style) {
150    if rect.width == 0 || rect.height == 0 {
151        return;
152    }
153
154    let mode = ColorMode::detect();
155    let prefix = style.prefix(mode);
156    let suffix = Style::suffix();
157
158    let needed_rows = rect.y as usize + rect.height as usize;
159    while target.lines.len() < needed_rows {
160        target.lines.push(String::new());
161    }
162
163    let top = rect.y as usize;
164    let bottom = (rect.y + rect.height - 1) as usize;
165    let inner_width = rect.width.saturating_sub(2) as usize;
166    let interior = " ".repeat(inner_width);
167
168    // Helper: build a horizontal edge (top or bottom)
169    let build_edge = |l: char, edge: char, r: char| -> String {
170        let mut line = String::new();
171        if l != ' ' {
172            line.push_str(&prefix);
173            line.push(l);
174            line.push_str(suffix);
175        }
176        if edge != ' ' && inner_width > 0 {
177            line.push_str(&prefix);
178            for _ in 0..inner_width {
179                line.push(edge);
180            }
181            line.push_str(suffix);
182        } else if inner_width > 0 {
183            line.push_str(&interior);
184        }
185        if r != ' ' && rect.width > 1 {
186            line.push_str(&prefix);
187            line.push(r);
188            line.push_str(suffix);
189        }
190        line
191    };
192
193    // Helper: build a middle row with left/right borders only
194    let build_side = |l: char, r: char| -> String {
195        let mut line = String::new();
196        if l != ' ' {
197            line.push_str(&prefix);
198            line.push(l);
199            line.push_str(suffix);
200        }
201        if inner_width > 0 {
202            line.push_str(&interior);
203        }
204        if r != ' ' && rect.width > 1 {
205            line.push_str(&prefix);
206            line.push(r);
207            line.push_str(suffix);
208        }
209        line
210    };
211
212    // Top edge
213    if rect.height >= 1 {
214        target.lines[top] = build_edge(border.top_left, border.top, border.top_right);
215    }
216
217    // Middle rows
218    for y in (top + 1)..bottom {
219        target.lines[y] = build_side(border.left, border.right);
220    }
221
222    // Bottom edge
223    if rect.height >= 2 {
224        target.lines[bottom] = build_edge(border.bottom_left, border.bottom, border.bottom_right);
225    }
226}
227
228#[cfg(test)]
229mod tests {
230    use super::*;
231    use crate::theme::{
232        Color,
233        Theme,
234    };
235
236    fn empty_rendered(_width: u16, height: u16) -> Rendered {
237        Rendered {
238            lines: vec![String::new(); height as usize],
239            cursor: None,
240            images: Vec::new(),
241        }
242    }
243
244    #[test]
245    fn thin_border_3x3() {
246        Theme::with(Theme::Light, || {
247            let mut target = empty_rendered(3, 3);
248            let rect = Rect::new(0, 0, 3, 3);
249            let style = Style::new().fg(Color::SUNBEAM_ORANGE);
250            draw_border(&mut target, rect, &Border::THIN, &style);
251
252            assert!(target.lines[0].contains('┌'));
253            assert!(target.lines[0].contains('┐'));
254            assert!(target.lines[1].contains('│'));
255            assert!(target.lines[2].contains('└'));
256            assert!(target.lines[2].contains('┘'));
257        });
258    }
259
260    #[test]
261    fn thin_border_5x3_with_title_space() {
262        Theme::with(Theme::Light, || {
263            let mut target = empty_rendered(5, 3);
264            let rect = Rect::new(0, 0, 5, 3);
265            let style = Style::new().fg(Color::WHITE);
266            draw_border(&mut target, rect, &Border::THIN, &style);
267
268            // Top: ┌───┐
269            assert!(target.lines[0].contains('┌'));
270            assert!(target.lines[0].contains('┐'));
271            // Middle: │   │
272            assert!(target.lines[1].contains('│'));
273            // Bottom: └───┘
274            assert!(target.lines[2].contains('└'));
275            assert!(target.lines[2].contains('┘'));
276        });
277    }
278
279    #[test]
280    fn border_includes_ansi_codes() {
281        Theme::with(Theme::Light, || {
282            let mut target = empty_rendered(3, 3);
283            let rect = Rect::new(0, 0, 3, 3);
284            let style = Style::new().fg(Color::SUNBEAM_ORANGE);
285            draw_border(&mut target, rect, &Border::THIN, &style);
286
287            // Border chars should be wrapped in ANSI codes
288            assert!(target.lines[0].starts_with('\x1b'));
289            assert!(target.lines[0].contains("\x1b[0m"));
290        });
291    }
292
293    #[test]
294    fn left_border_only() {
295        Theme::with(Theme::Light, || {
296            let mut target = empty_rendered(3, 3);
297            let rect = Rect::new(0, 0, 3, 3);
298            let style = Style::new().fg(Color::SUNBEAM_ORANGE);
299            draw_border(&mut target, rect, &Border::LEFT, &style);
300
301            assert!(target.lines[0].contains('▐'));
302            assert!(target.lines[1].contains('▐'));
303            assert!(target.lines[2].contains('▐'));
304            // No right border
305            assert!(!target.lines[1].contains('│'));
306        });
307    }
308
309    #[test]
310    fn border_inner_rect() {
311        let border = Border::THIN;
312        let outer = Rect::new(0, 0, 10, 5);
313        let inner = border.inner(outer);
314        assert_eq!(inner.x, 1);
315        assert_eq!(inner.y, 1);
316        assert_eq!(inner.width, 8);
317        assert_eq!(inner.height, 3);
318    }
319
320    #[test]
321    fn empty_border_inner_is_full() {
322        let border = Border::NONE;
323        let outer = Rect::new(0, 0, 10, 5);
324        let inner = border.inner(outer);
325        assert_eq!(inner, outer);
326    }
327}