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