Skip to main content

rusty_rich/
box_drawing.rs

1//! Box drawing — equivalent to Rich's `box.py`.
2//!
3//! Defines various box styles (ROUNDED, SQUARE, HEAVY, etc.) using Unicode
4//! box-drawing characters, with ASCII-safe fallbacks.
5
6// ---------------------------------------------------------------------------
7// Box — defines characters for drawing a bordered box
8// ---------------------------------------------------------------------------
9
10/// A set of box-drawing characters defining the look of borders.
11///
12/// Layout of the 8-line string that defines a box:
13///
14/// ```text
15/// ┌─┬┐ top
16/// │ ││ head
17/// ├─┼┤ head_row
18/// │ ││ mid
19/// ├─┼┤ row
20/// ├─┼┤ foot_row
21/// │ ││ foot
22/// └─┴┘ bottom
23/// ```
24#[derive(Debug, Clone, PartialEq, Eq)]
25pub struct BoxStyle {
26    // top row
27    pub top_left: char,
28    pub top: char,
29    pub top_divider: char,
30    pub top_right: char,
31    // head row (where content is on same line as top border)
32    pub head_left: char,
33    pub head_horizontal: char,
34    pub head_vertical: char,
35    pub head_right: char,
36    // head_row (separator after header)
37    pub head_row_left: char,
38    pub head_row_horizontal: char,
39    pub head_row_cross: char,
40    pub head_row_right: char,
41    // mid (between rows when show_lines is off)
42    pub mid_left: char,
43    pub mid_horizontal: char,
44    pub mid_vertical: char,
45    pub mid_right: char,
46    // row (between rows when show_lines is on)
47    pub row_left: char,
48    pub row_horizontal: char,
49    pub row_cross: char,
50    pub row_right: char,
51    // foot_row (separator before footer)
52    pub foot_row_left: char,
53    pub foot_row_horizontal: char,
54    pub foot_row_cross: char,
55    pub foot_row_right: char,
56    // foot
57    pub foot_left: char,
58    pub foot_horizontal: char,
59    pub foot_vertical: char,
60    pub foot_right: char,
61    // bottom row
62    pub bottom_left: char,
63    pub bottom: char,
64    pub bottom_divider: char,
65    pub bottom_right: char,
66    /// True if this box uses only ASCII characters.
67    pub ascii: bool,
68}
69
70impl BoxStyle {
71    /// Returns true if this box has visible outer edges (non-space corners).
72    /// Edge-less styles like SIMPLE, MINIMAL, and MARKDOWN return `false`
73    /// because their corner characters are all spaces — they are designed
74    /// to be used in tables where internal separators provide structure.
75    pub fn has_visible_edges(&self) -> bool {
76        // A visible edge requires at least one non-space corner.
77        self.top_left != ' '
78            || self.top_right != ' '
79            || self.bottom_left != ' '
80            || self.bottom_right != ' '
81    }
82
83    /// Parse a box style from an 8-line string.
84    pub fn from_str(box_str: &str, ascii: bool) -> Self {
85        let lines: Vec<&str> = box_str.lines().collect();
86        assert_eq!(lines.len(), 8, "Box definition must have exactly 8 lines");
87
88        let line_chars: Vec<Vec<char>> = lines.iter().map(|l| l.chars().collect()).collect();
89
90        // Each line should have 4 characters
91        for (i, chars) in line_chars.iter().enumerate() {
92            assert_eq!(chars.len(), 4, "Line {i} must have exactly 4 characters");
93        }
94
95        let l = &line_chars;
96        Self {
97            top_left: l[0][0],
98            top: l[0][1],
99            top_divider: l[0][2],
100            top_right: l[0][3],
101            head_left: l[1][0],
102            head_horizontal: l[1][1],
103            head_vertical: l[1][2],
104            head_right: l[1][3],
105            head_row_left: l[2][0],
106            head_row_horizontal: l[2][1],
107            head_row_cross: l[2][2],
108            head_row_right: l[2][3],
109            mid_left: l[3][0],
110            mid_horizontal: l[3][1],
111            mid_vertical: l[3][2],
112            mid_right: l[3][3],
113            row_left: l[4][0],
114            row_horizontal: l[4][1],
115            row_cross: l[4][2],
116            row_right: l[4][3],
117            foot_row_left: l[5][0],
118            foot_row_horizontal: l[5][1],
119            foot_row_cross: l[5][2],
120            foot_row_right: l[5][3],
121            foot_left: l[6][0],
122            foot_horizontal: l[6][1],
123            foot_vertical: l[6][2],
124            foot_right: l[6][3],
125            bottom_left: l[7][0],
126            bottom: l[7][1],
127            bottom_divider: l[7][2],
128            bottom_right: l[7][3],
129            ascii,
130        }
131    }
132
133    /// Get the plain text representation of the box definition.
134    pub fn to_plain_text(&self) -> String {
135        format!(
136            "{}{}{}{}\n{}{}{}{}\n{}{}{}{}\n{}{}{}{}\n{}{}{}{}\n{}{}{}{}\n{}{}{}{}\n{}{}{}{}",
137            self.top_left,
138            self.top,
139            self.top_divider,
140            self.top_right,
141            self.head_left,
142            self.head_horizontal,
143            self.head_vertical,
144            self.head_right,
145            self.head_row_left,
146            self.head_row_horizontal,
147            self.head_row_cross,
148            self.head_row_right,
149            self.mid_left,
150            self.mid_horizontal,
151            self.mid_vertical,
152            self.mid_right,
153            self.row_left,
154            self.row_horizontal,
155            self.row_cross,
156            self.row_right,
157            self.foot_row_left,
158            self.foot_row_horizontal,
159            self.foot_row_cross,
160            self.foot_row_right,
161            self.foot_left,
162            self.foot_horizontal,
163            self.foot_vertical,
164            self.foot_right,
165            self.bottom_left,
166            self.bottom,
167            self.bottom_divider,
168            self.bottom_right,
169        )
170    }
171}
172
173impl std::fmt::Display for BoxStyle {
174    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
175        write!(f, "{}", self.to_plain_text())
176    }
177}
178
179// ---------------------------------------------------------------------------
180// Predefined box styles (matching Rich's defaults)
181// ---------------------------------------------------------------------------
182
183/// ASCII-only box.
184pub const ASCII: &str = "\
185+--+
186| ||
187|-+|
188| ||
189|-+|
190|-+|
191| ||
192+--+";
193
194/// ASCII with double edges (no distinct header).
195pub const ASCII2: &str = "\
196+-++
197| ||
198+-++
199| ||
200+-++
201+-++
202| ||
203+-++";
204
205/// Square box with double horizontal header separator.
206pub const SQUARE_DOUBLE_HEAD: &str = "\
207┌─┬┐
208│ ││
209╞═╪╡
210│ ││
211├─┼┤
212├─┼┤
213│ ││
214└─┴┘";
215
216/// Minimal box with double horizontal separator (head row only).
217pub const MINIMAL_DOUBLE_HEAD: &str = "  ╷ \n  │ \n ═╪ \n  │ \n ─┼ \n ─┼ \n  │ \n  ╵ ";
218
219/// Simple box with a single horizontal rule under the header.
220pub const SIMPLE_HEAD: &str = "    \n    \n ── \n    \n    \n    \n    \n    ";
221
222/// ASCII box style with a double header line.
223pub const ASCII_DOUBLE_HEAD: &str = "\
224+-++
225| ||
226+=++
227| ||
228+-++
229+-++
230| ||
231+-++";
232
233/// Rounded corners.
234pub const ROUNDED: &str = "\
235╭─┬╮
236│ ││
237├─┼┤
238│ ││
239├─┼┤
240├─┼┤
241│ ││
242╰─┴╯";
243
244/// Square corners.
245pub const SQUARE: &str = "\
246┌─┬┐
247│ ││
248├─┼┤
249│ ││
250├─┼┤
251├─┼┤
252│ ││
253└─┴┘";
254
255/// Heavy borders.
256pub const HEAVY: &str = "\
257┏━┳┓
258┃ ┃┃
259┣━╋┫
260┃ ┃┃
261┣━╋┫
262┣━╋┫
263┃ ┃┃
264┗━┻┛";
265
266/// Heavy edge, light inner.
267pub const HEAVY_EDGE: &str = "\
268┏━┯┓
269┃ │┃
270┠─┼┨
271┃ │┃
272┠─┼┨
273┠─┼┨
274┃ │┃
275┗━┷┛";
276
277/// Heavy header.
278pub const HEAVY_HEAD: &str = "\
279┏━┳┓
280┃ ┃┃
281┡━╇┩
282│ ││
283├─┼┤
284├─┼┤
285│ ││
286└─┴┘";
287
288/// Double borders.
289pub const DOUBLE: &str = "\
290╔═╦╗
291║ ║║
292╠═╬╣
293║ ║║
294╠═╬╣
295╠═╬╣
296║ ║║
297╚═╩╝";
298
299/// Double edge (like DOUBLE but inner is single).
300pub const DOUBLE_EDGE: &str = "\
301╔═╤╗
302║ │║
303╟─┼╢
304║ │║
305╟─┼╢
306╟─┼╢
307║ │║
308╚═╧╝";
309
310/// Simple (no borders, just vertical separators).
311pub const SIMPLE: &str = "    \n    \n ── \n    \n    \n ── \n    \n    ";
312
313/// Simple with heavy header.
314pub const SIMPLE_HEAVY: &str = "    \n    \n ━━ \n    \n    \n ━━ \n    \n    ";
315
316/// Minimal (thin rule, vertical separators, no outer edges).
317pub const MINIMAL: &str = "  ╷ \n  │ \n╶─┼╴\n  │ \n╶─┼╴\n╶─┼╴\n  │ \n  ╵ ";
318
319/// Minimal with heavy header separator (matches Python Rich MINIMAL_HEAVY_HEAD).
320pub const MINIMAL_HEAVY: &str = "  ╷ \n  │ \n╺━┿╸\n  │ \n╶─┼╴\n╶─┼╴\n  │ \n  ╵ ";
321
322// ---------------------------------------------------------------------------
323// Box style constants (lazily parsed)
324// ---------------------------------------------------------------------------
325
326use std::sync::LazyLock;
327
328/// Rounded box (default for Panel).
329pub static BOX_ROUNDED: LazyLock<BoxStyle> = LazyLock::new(|| BoxStyle::from_str(ROUNDED, false));
330/// Square-cornered box.
331pub static BOX_SQUARE: LazyLock<BoxStyle> = LazyLock::new(|| BoxStyle::from_str(SQUARE, false));
332/// Heavy (thick) borders.
333pub static BOX_HEAVY: LazyLock<BoxStyle> = LazyLock::new(|| BoxStyle::from_str(HEAVY, false));
334/// Heavy outer edges with light inner dividers.
335pub static BOX_HEAVY_EDGE: LazyLock<BoxStyle> =
336    LazyLock::new(|| BoxStyle::from_str(HEAVY_EDGE, false));
337/// Heavy header row with regular body borders.
338pub static BOX_HEAVY_HEAD: LazyLock<BoxStyle> =
339    LazyLock::new(|| BoxStyle::from_str(HEAVY_HEAD, false));
340/// Double-line borders.
341pub static BOX_DOUBLE: LazyLock<BoxStyle> = LazyLock::new(|| BoxStyle::from_str(DOUBLE, false));
342/// Double outer edge with single inner dividers.
343pub static BOX_DOUBLE_EDGE: LazyLock<BoxStyle> =
344    LazyLock::new(|| BoxStyle::from_str(DOUBLE_EDGE, false));
345/// Simple borders (no vertical edges, horizontal rules only).
346pub static BOX_SIMPLE: LazyLock<BoxStyle> = LazyLock::new(|| BoxStyle::from_str(SIMPLE, false));
347/// Simple borders with heavy horizontal rules.
348pub static BOX_SIMPLE_HEAVY: LazyLock<BoxStyle> =
349    LazyLock::new(|| BoxStyle::from_str(SIMPLE_HEAVY, false));
350/// Minimal box (just horizontal separators between header/body).
351pub static BOX_MINIMAL: LazyLock<BoxStyle> = LazyLock::new(|| BoxStyle::from_str(MINIMAL, false));
352/// Minimal box with heavy horizontal separators.
353pub static BOX_MINIMAL_HEAVY: LazyLock<BoxStyle> =
354    LazyLock::new(|| BoxStyle::from_str(MINIMAL_HEAVY, false));
355/// ASCII-only box (uses `+`, `-`, `|` characters).
356pub static BOX_ASCII: LazyLock<BoxStyle> = LazyLock::new(|| BoxStyle::from_str(ASCII, true));
357/// ASCII box with doubled edges.
358pub static BOX_ASCII2: LazyLock<BoxStyle> = LazyLock::new(|| BoxStyle::from_str(ASCII2, true));
359/// Square box with a double horizontal header separator.
360pub static BOX_SQUARE_DOUBLE_HEAD: LazyLock<BoxStyle> =
361    LazyLock::new(|| BoxStyle::from_str(SQUARE_DOUBLE_HEAD, false));
362/// Minimal box with a double horizontal header separator.
363pub static BOX_MINIMAL_DOUBLE_HEAD: LazyLock<BoxStyle> =
364    LazyLock::new(|| BoxStyle::from_str(MINIMAL_DOUBLE_HEAD, false));
365/// Simple box with a single horizontal rule under the header.
366pub static BOX_SIMPLE_HEAD: LazyLock<BoxStyle> =
367    LazyLock::new(|| BoxStyle::from_str(SIMPLE_HEAD, false));
368/// ASCII box with a double header line.
369pub static BOX_ASCII_DOUBLE_HEAD: LazyLock<BoxStyle> =
370    LazyLock::new(|| BoxStyle::from_str(ASCII_DOUBLE_HEAD, true));
371
372// ---------------------------------------------------------------------------
373// MARKDOWN box (no outer border)
374// ---------------------------------------------------------------------------
375
376/// Markdown-style box definition string (no outer borders).
377pub const MARKDOWN: &str = "    \n| ||\n|-||\n| ||\n|-||\n|-||\n| ||\n    ";
378
379/// Markdown-style box (no outer edges, vertical separators only).
380pub static BOX_MARKDOWN: LazyLock<BoxStyle> = LazyLock::new(|| BoxStyle::from_str(MARKDOWN, false));
381
382// ---------------------------------------------------------------------------
383// Safe box (for Windows legacy terminals)
384// ---------------------------------------------------------------------------
385
386/// Return an ASCII-safe version of a box if needed.
387pub fn get_safe_box(box_style: &BoxStyle, ascii_only: bool) -> BoxStyle {
388    if ascii_only && !box_style.ascii {
389        BOX_ASCII.clone()
390    } else {
391        box_style.clone()
392    }
393}
394
395#[cfg(test)]
396mod tests {
397    use super::*;
398
399    #[test]
400    fn test_rounded_box() {
401        let b = &*BOX_ROUNDED;
402        assert_eq!(b.top_left, '╭');
403        assert_eq!(b.bottom_right, '╯');
404    }
405
406    #[test]
407    fn test_box_from_str() {
408        let b = BoxStyle::from_str(ROUNDED, false);
409        assert_eq!(b, *BOX_ROUNDED);
410    }
411
412    #[test]
413    fn test_new_box_styles_parse() {
414        // Verify that the new box styles parse without panicking
415        let _ = &*BOX_SQUARE_DOUBLE_HEAD;
416        let _ = &*BOX_MINIMAL_DOUBLE_HEAD;
417        let _ = &*BOX_SIMPLE_HEAD;
418        let _ = &*BOX_ASCII_DOUBLE_HEAD;
419
420        // Spot-check characters
421        let sq = &*BOX_SQUARE_DOUBLE_HEAD;
422        assert_eq!(sq.top_left, '┌');
423        assert_eq!(sq.head_row_horizontal, '═');
424        assert_eq!(sq.head_row_left, '╞');
425
426        let ac = &*BOX_ASCII_DOUBLE_HEAD;
427        assert_eq!(ac.head_row_left, '+');
428        assert_eq!(ac.head_row_horizontal, '=');
429        assert_eq!(ac.row_left, '+');
430    }
431}