Skip to main content

rich/
box.rs

1//! Box-drawing character sets.
2//!
3//! Port of upstream `rich/box.py`. A [`Box`] is parsed from an 8-line template
4//! (exactly as upstream), giving named access to every corner, edge, and
5//! junction. Panels, rules, and (later) tables draw their borders from these.
6//!
7//! All of upstream's built-in boxes are provided, along with [`Box::substitute`]
8//! — the platform-dependent fallback applied on legacy Windows consoles (fancy
9//! boxes → `SQUARE`) and non-UTF-8 terminals (→ `ASCII`).
10
11/// Which divider a [`Box::get_row`] draws.
12#[derive(Debug, Clone, Copy, PartialEq, Eq)]
13pub enum RowLevel {
14    /// Separator below the header (`head_row_*`).
15    Head,
16    /// Separator between body rows (`row_*`).
17    Row,
18    /// Separator above the footer (`foot_row_*`).
19    Foot,
20}
21
22/// A set of box-drawing characters. Mirrors `rich.box.Box`.
23///
24/// The field names match upstream's 8×4 grid:
25/// ```text
26/// top_left    top              top_divider     top_right
27/// head_left   (space)          head_vertical   head_right
28/// head_row_left head_row_horizontal head_row_cross head_row_right
29/// mid_left    (space)          mid_vertical    mid_right
30/// row_left    row_horizontal   row_cross       row_right
31/// foot_row_left foot_row_horizontal foot_row_cross foot_row_right
32/// foot_left   (space)          foot_vertical   foot_right
33/// bottom_left bottom           bottom_divider  bottom_right
34/// ```
35#[derive(Debug, Clone, Copy, PartialEq, Eq)]
36pub struct Box {
37    pub top_left: char,
38    pub top: char,
39    pub top_divider: char,
40    pub top_right: char,
41
42    pub head_left: char,
43    pub head_vertical: char,
44    pub head_right: char,
45
46    pub head_row_left: char,
47    pub head_row_horizontal: char,
48    pub head_row_cross: char,
49    pub head_row_right: char,
50
51    pub mid_left: char,
52    pub mid_vertical: char,
53    pub mid_right: char,
54
55    pub row_left: char,
56    pub row_horizontal: char,
57    pub row_cross: char,
58    pub row_right: char,
59
60    pub foot_row_left: char,
61    pub foot_row_horizontal: char,
62    pub foot_row_cross: char,
63    pub foot_row_right: char,
64
65    pub foot_left: char,
66    pub foot_vertical: char,
67    pub foot_right: char,
68
69    pub bottom_left: char,
70    pub bottom: char,
71    pub bottom_divider: char,
72    pub bottom_right: char,
73}
74
75impl Box {
76    /// Parse a box from the 8-line, 4-column template (as upstream does).
77    ///
78    /// Panics at const-eval time if the template is malformed, so the built-in
79    /// constants are validated when the crate compiles.
80    const fn parse(template: &str) -> Box {
81        let rows = split_rows(template);
82        Box {
83            top_left: rows[0][0],
84            top: rows[0][1],
85            top_divider: rows[0][2],
86            top_right: rows[0][3],
87
88            head_left: rows[1][0],
89            head_vertical: rows[1][2],
90            head_right: rows[1][3],
91
92            head_row_left: rows[2][0],
93            head_row_horizontal: rows[2][1],
94            head_row_cross: rows[2][2],
95            head_row_right: rows[2][3],
96
97            mid_left: rows[3][0],
98            mid_vertical: rows[3][2],
99            mid_right: rows[3][3],
100
101            row_left: rows[4][0],
102            row_horizontal: rows[4][1],
103            row_cross: rows[4][2],
104            row_right: rows[4][3],
105
106            foot_row_left: rows[5][0],
107            foot_row_horizontal: rows[5][1],
108            foot_row_cross: rows[5][2],
109            foot_row_right: rows[5][3],
110
111            foot_left: rows[6][0],
112            foot_vertical: rows[6][2],
113            foot_right: rows[6][3],
114
115            bottom_left: rows[7][0],
116            bottom: rows[7][1],
117            bottom_divider: rows[7][2],
118            bottom_right: rows[7][3],
119        }
120    }
121
122    /// The top border for the given column `widths`. Port of `Box.get_top`.
123    /// `edge` controls whether the left/right corner glyphs are drawn.
124    pub fn get_top(&self, widths: &[usize], edge: bool) -> String {
125        let mut parts = String::new();
126        if edge {
127            parts.push(self.top_left);
128        }
129        let last = widths.len().saturating_sub(1);
130        for (index, &width) in widths.iter().enumerate() {
131            for _ in 0..width {
132                parts.push(self.top);
133            }
134            if index != last {
135                parts.push(self.top_divider);
136            }
137        }
138        if edge {
139            parts.push(self.top_right);
140        }
141        parts
142    }
143
144    /// A horizontal divider row between columns at a given `level`. Port of
145    /// `Box.get_row`. `edge` controls whether the left/right glyphs are drawn.
146    pub fn get_row(&self, widths: &[usize], level: RowLevel, edge: bool) -> String {
147        let (left, horizontal, cross, right) = match level {
148            RowLevel::Head => (
149                self.head_row_left,
150                self.head_row_horizontal,
151                self.head_row_cross,
152                self.head_row_right,
153            ),
154            RowLevel::Row => (
155                self.row_left,
156                self.row_horizontal,
157                self.row_cross,
158                self.row_right,
159            ),
160            RowLevel::Foot => (
161                self.foot_row_left,
162                self.foot_row_horizontal,
163                self.foot_row_cross,
164                self.foot_row_right,
165            ),
166        };
167        let mut parts = String::new();
168        if edge {
169            parts.push(left);
170        }
171        let last = widths.len().saturating_sub(1);
172        for (index, &width) in widths.iter().enumerate() {
173            for _ in 0..width {
174                parts.push(horizontal);
175            }
176            if index != last {
177                parts.push(cross);
178            }
179        }
180        if edge {
181            parts.push(right);
182        }
183        parts
184    }
185
186    /// Return a version of this box safe for the target terminal. Port of
187    /// `Box.substitute`.
188    ///
189    /// On a legacy Windows console (`legacy_windows` + `safe`), the fancy boxes
190    /// that legacy code pages can't draw — `ROUNDED`, `HEAVY`, `HEAVY_HEAD` —
191    /// fall back to `SQUARE` (`DOUBLE`/`SQUARE`/`MINIMAL` are kept). On a
192    /// non-UTF-8 terminal (`ascii_only`), any non-ASCII box becomes `ASCII`.
193    pub fn substitute(&self, legacy_windows: bool, safe: bool, ascii_only: bool) -> Box {
194        let mut result = *self;
195        if legacy_windows && safe && (result == ROUNDED || result == HEAVY || result == HEAVY_HEAD)
196        {
197            result = SQUARE;
198        }
199        if ascii_only && result != ASCII {
200            result = ASCII;
201        }
202        result
203    }
204
205    /// The bottom border for the given column `widths`. Port of `Box.get_bottom`.
206    /// `edge` controls whether the left/right corner glyphs are drawn.
207    pub fn get_bottom(&self, widths: &[usize], edge: bool) -> String {
208        let mut parts = String::new();
209        if edge {
210            parts.push(self.bottom_left);
211        }
212        let last = widths.len().saturating_sub(1);
213        for (index, &width) in widths.iter().enumerate() {
214            for _ in 0..width {
215                parts.push(self.bottom);
216            }
217            if index != last {
218                parts.push(self.bottom_divider);
219            }
220        }
221        if edge {
222            parts.push(self.bottom_right);
223        }
224        parts
225    }
226}
227
228/// Split an 8-line template into an `[8][4]` grid of chars (const-eval helper).
229const fn split_rows(template: &str) -> [[char; 4]; 8] {
230    let bytes = template.as_bytes();
231    let mut rows = [[' '; 4]; 8];
232    // We iterate chars manually because box glyphs are multi-byte UTF-8 and the
233    // template has a fixed shape: 4 columns per line, newline-separated.
234    let mut i = 0usize; // byte index
235    let mut row = 0usize;
236    let mut col = 0usize;
237    while i < bytes.len() {
238        let (ch, width) = next_char(bytes, i);
239        if ch == '\n' {
240            row += 1;
241            col = 0;
242            i += width;
243            continue;
244        }
245        if row < 8 && col < 4 {
246            rows[row][col] = ch;
247        }
248        col += 1;
249        i += width;
250    }
251    rows
252}
253
254/// Decode one UTF-8 char starting at `bytes[i]`, returning it and its byte len.
255/// A small const-fn UTF-8 decoder (std's `chars()` isn't const).
256const fn next_char(bytes: &[u8], i: usize) -> (char, usize) {
257    let b0 = bytes[i];
258    if b0 < 0x80 {
259        (b0 as char, 1)
260    } else if b0 >> 5 == 0b110 {
261        let cp = ((b0 as u32 & 0x1f) << 6) | (bytes[i + 1] as u32 & 0x3f);
262        (char_from_u32(cp), 2)
263    } else if b0 >> 4 == 0b1110 {
264        let cp = ((b0 as u32 & 0x0f) << 12)
265            | ((bytes[i + 1] as u32 & 0x3f) << 6)
266            | (bytes[i + 2] as u32 & 0x3f);
267        (char_from_u32(cp), 3)
268    } else {
269        let cp = ((b0 as u32 & 0x07) << 18)
270            | ((bytes[i + 1] as u32 & 0x3f) << 12)
271            | ((bytes[i + 2] as u32 & 0x3f) << 6)
272            | (bytes[i + 3] as u32 & 0x3f);
273        (char_from_u32(cp), 4)
274    }
275}
276
277const fn char_from_u32(cp: u32) -> char {
278    match char::from_u32(cp) {
279        Some(c) => c,
280        None => '\u{fffd}',
281    }
282}
283
284// ── Built-in boxes (subset of upstream `rich/box.py`) ──
285
286pub const ASCII: Box = Box::parse("+--+\n| ||\n|-+|\n| ||\n|-+|\n|-+|\n| ||\n+--+\n");
287
288pub const SQUARE: Box = Box::parse("┌─┬┐\n│ ││\n├─┼┤\n│ ││\n├─┼┤\n├─┼┤\n│ ││\n└─┴┘\n");
289
290pub const ROUNDED: Box = Box::parse("╭─┬╮\n│ ││\n├─┼┤\n│ ││\n├─┼┤\n├─┼┤\n│ ││\n╰─┴╯\n");
291
292pub const HEAVY: Box = Box::parse("┏━┳┓\n┃ ┃┃\n┣━╋┫\n┃ ┃┃\n┣━╋┫\n┣━╋┫\n┃ ┃┃\n┗━┻┛\n");
293
294/// The default `Table` box: heavy top border + head separator, light body.
295pub const HEAVY_HEAD: Box = Box::parse("┏━┳┓\n┃ ┃┃\n┡━╇┩\n│ ││\n├─┼┤\n├─┼┤\n│ ││\n└─┴┘\n");
296
297pub const DOUBLE: Box = Box::parse("╔═╦╗\n║ ║║\n╠═╬╣\n║ ║║\n╠═╬╣\n╠═╬╣\n║ ║║\n╚═╩╝\n");
298
299pub const MINIMAL: Box = Box::parse("  ╷ \n  │ \n╶─┼╴\n  │ \n╶─┼╴\n╶─┼╴\n  │ \n  ╵ \n");
300
301pub const ASCII2: Box = Box::parse("+-++\n| ||\n+-++\n| ||\n+-++\n+-++\n| ||\n+-++\n");
302
303pub const ASCII_DOUBLE_HEAD: Box = Box::parse("+-++\n| ||\n+=++\n| ||\n+-++\n+-++\n| ||\n+-++\n");
304
305pub const SQUARE_DOUBLE_HEAD: Box = Box::parse("┌─┬┐\n│ ││\n╞═╪╡\n│ ││\n├─┼┤\n├─┼┤\n│ ││\n└─┴┘\n");
306
307pub const MINIMAL_HEAVY_HEAD: Box = Box::parse("  ╷ \n  │ \n╺━┿╸\n  │ \n╶─┼╴\n╶─┼╴\n  │ \n  ╵ \n");
308
309pub const MINIMAL_DOUBLE_HEAD: Box = Box::parse("  ╷ \n  │ \n ═╪ \n  │ \n ─┼ \n ─┼ \n  │ \n  ╵ \n");
310
311/// A fully blank box (all spaces) — no visible borders. Port of `box.NONE`.
312pub const NONE: Box = Box::parse("    \n    \n    \n    \n    \n    \n    \n    \n");
313
314/// A boxless table with a light head/foot rule. Used by Markdown tables.
315pub const SIMPLE: Box = Box::parse("    \n    \n ── \n    \n    \n ── \n    \n    \n");
316
317pub const SIMPLE_HEAD: Box = Box::parse("    \n    \n ── \n    \n    \n    \n    \n    \n");
318
319pub const SIMPLE_HEAVY: Box = Box::parse("    \n    \n ━━ \n    \n    \n ━━ \n    \n    \n");
320
321pub const HORIZONTALS: Box = Box::parse(" ── \n    \n ── \n    \n ── \n ── \n    \n ── \n");
322
323pub const HEAVY_EDGE: Box = Box::parse("┏━┯┓\n┃ │┃\n┠─┼┨\n┃ │┃\n┠─┼┨\n┠─┼┨\n┃ │┃\n┗━┷┛\n");
324
325pub const DOUBLE_EDGE: Box = Box::parse("╔═╤╗\n║ │║\n╟─┼╢\n║ │║\n╟─┼╢\n╟─┼╢\n║ │║\n╚═╧╝\n");
326
327/// The box Markdown tables use for GFM output (pipes + a light head rule).
328pub const MARKDOWN: Box = Box::parse("    \n| ||\n|-||\n| ||\n|-||\n|-||\n| ||\n    \n");
329
330#[cfg(test)]
331mod tests {
332    use super::*;
333
334    #[test]
335    fn rounded_corners() {
336        assert_eq!(ROUNDED.top_left, '╭');
337        assert_eq!(ROUNDED.top_right, '╮');
338        assert_eq!(ROUNDED.bottom_left, '╰');
339        assert_eq!(ROUNDED.bottom_right, '╯');
340        assert_eq!(ROUNDED.top, '─');
341        assert_eq!(ROUNDED.mid_left, '│');
342        assert_eq!(ROUNDED.mid_right, '│');
343    }
344
345    #[test]
346    fn square_and_ascii() {
347        assert_eq!(SQUARE.top_left, '┌');
348        assert_eq!(ASCII.top_left, '+');
349        assert_eq!(ASCII.top, '-');
350        assert_eq!(ASCII.mid_left, '|');
351    }
352
353    #[test]
354    fn get_top_and_bottom_single_column() {
355        assert_eq!(ROUNDED.get_top(&[3], true), "╭───╮");
356        assert_eq!(ROUNDED.get_bottom(&[3], true), "╰───╯");
357        // Without edges, the corners are omitted.
358        assert_eq!(ROUNDED.get_top(&[3], false), "───");
359        assert_eq!(SQUARE.get_row(&[2, 2], RowLevel::Head, false), "──┼──");
360    }
361
362    #[test]
363    fn additional_boxes_parse() {
364        // SIMPLE / MARKDOWN have blank edges and a head rule.
365        assert_eq!(SIMPLE.top_left, ' ');
366        assert_eq!(SIMPLE.head_row_horizontal, '─');
367        assert_eq!(SIMPLE_HEAVY.head_row_horizontal, '━');
368        assert_eq!(MARKDOWN.mid_left, '|');
369        assert_eq!(MARKDOWN.head_row_horizontal, '-');
370        assert_eq!(DOUBLE_EDGE.top_left, '╔');
371        assert_eq!(DOUBLE_EDGE.mid_vertical, '│');
372        assert_eq!(HEAVY_EDGE.top_left, '┏');
373        assert_eq!(SQUARE_DOUBLE_HEAD.head_row_horizontal, '═');
374        assert_eq!(ASCII2.head_row_cross, '+');
375    }
376
377    #[test]
378    fn substitute_legacy_and_ascii() {
379        // Legacy Windows: fancy → SQUARE; DOUBLE/SQUARE kept.
380        assert_eq!(ROUNDED.substitute(true, true, false), SQUARE);
381        assert_eq!(HEAVY.substitute(true, true, false), SQUARE);
382        assert_eq!(HEAVY_HEAD.substitute(true, true, false), SQUARE);
383        assert_eq!(DOUBLE.substitute(true, true, false), DOUBLE);
384        assert_eq!(SQUARE.substitute(true, true, false), SQUARE);
385        // `safe=false` disables the legacy fallback.
386        assert_eq!(ROUNDED.substitute(true, false, false), ROUNDED);
387        // Non-UTF-8: anything non-ASCII → ASCII.
388        assert_eq!(ROUNDED.substitute(false, true, true), ASCII);
389        assert_eq!(DOUBLE.substitute(false, true, true), ASCII);
390        assert_eq!(ASCII.substitute(false, true, true), ASCII);
391        // No flags → unchanged.
392        assert_eq!(ROUNDED.substitute(false, true, false), ROUNDED);
393    }
394}