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