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