evobench_tools/output_table/
terminal.rs

1//! Experimental attempt at a table printing abstraction that can both
2//! print to a terminal in nice human-readable format (with spaces for
3//! padding, and ANSI sequences for formatting), as well as in CSV
4//! (with tabs) format.
5
6//! Does not currently escape anything in the fields, just uses
7//! `Display` and prints that directly. Thus is not safe if the type
8//! can print tabs or newlines (or on the terminal even spaces could
9//! make it ambiguous).
10
11use std::{
12    io::{BufWriter, IsTerminal, Write},
13    os::unix::ffi::OsStrExt,
14};
15
16use crate::{
17    output_table::{BarKind, CellValue, OutputStyle, OutputTable, OutputTableTitle, Row},
18    utillib::get_terminal_width::get_terminal_width,
19};
20use anyhow::{Result, anyhow, bail};
21use lazy_static::lazy_static;
22use strum_macros::EnumString;
23use yansi::{Color, Paint, Style};
24
25impl From<OutputStyle> for Style {
26    fn from(value: OutputStyle) -> Self {
27        let OutputStyle {
28            faded,
29            bold,
30            italic,
31            color,
32            font_size: _,
33        } = value;
34        let mut style = Style::new();
35        if faded {
36            // Note: needs `TERM=xterm-256color`
37            // for `watch --color` to not turn
38            // this color to black!
39            style = style.bright_black()
40        }
41        if bold {
42            style = style.bold()
43        }
44        if italic {
45            style = style.italic()
46        }
47        if let Some(col) = color {
48            // Note: in spite of `TERM=xterm-256color`, `watch
49            // --color` still only supports system colors
50            // 0..14!  (Can still not use `.rgb(10, 70, 140)`
51            // nor `.fg(Color::Fixed(30))`, and watch 4.0.2
52            // does not support `TERM=xterm-truecolor`.)
53            style = style.fg(Color::Fixed(col))
54        }
55
56        style
57    }
58}
59
60lazy_static! {
61    static ref UNICODE_IS_FINE: bool = (|| -> Option<bool> {
62        let term = std::env::var_os("TERM")?;
63        let lang = std::env::var_os("LANG")?;
64        let lang = lang.to_str()?;
65        Some(term.as_bytes().starts_with(b"xterm") && lang.contains("UTF-8"))
66    })()
67    .unwrap_or(false);
68}
69
70#[derive(Debug, EnumString, PartialEq, Clone, Copy)]
71#[strum(serialize_all = "kebab_case")]
72pub enum ColorOpt {
73    Auto,
74    Always,
75    Never,
76}
77
78impl ColorOpt {
79    pub fn want_color(self, detected_terminal: bool) -> bool {
80        match self {
81            ColorOpt::Auto => detected_terminal,
82            ColorOpt::Always => true,
83            ColorOpt::Never => false,
84        }
85    }
86}
87
88#[derive(Debug, clap::Args, Clone)]
89pub struct TerminalTableOpts {
90    /// Show the table as CSV (with '\t' as separator) instead of
91    /// human-readable
92    #[clap(long)]
93    tsv: bool,
94
95    /// Whether to use ANSI codes to format human-readable output on
96    /// terminals (auto, always, never)
97    #[clap(long, default_value = "auto")]
98    color: ColorOpt,
99}
100
101impl TerminalTableOpts {
102    pub fn want_color(&self, detected_terminal: bool) -> bool {
103        let Self { tsv, color } = self;
104        if *tsv {
105            false
106        } else {
107            color.want_color(detected_terminal)
108        }
109    }
110}
111
112struct TerminalTableSettings {
113    widths: Vec<usize>,
114    padding: String,
115    is_terminal: bool,
116}
117
118/// Capable of streaming, which requires defining the column widths
119/// beforehand. If a value is wider than the defined column width for
120/// that value, a single space is still printed between the value and
121/// the next. The last column does not need a width, and no padding is
122/// printed.
123pub struct TerminalTable<O: Write + IsTerminal> {
124    pub opts: TerminalTableOpts,
125    settings: TerminalTableSettings,
126    thin_bar: String,
127    thick_bar: String,
128    out: BufWriter<O>,
129}
130
131impl<O: Write + IsTerminal> TerminalTable<O> {
132    /// How many spaces to put between columns in human-readable
133    /// format at minimum, even if a value is longer than anticipated.
134    const MINIMAL_PADDING_LEN: usize = 1;
135
136    /// The length of `widths` must be one less than that of `titles`
137    /// (the last column does not need a width).  Appends a space to
138    /// each title (or generally, formatted item), to make sure italic
139    /// text is not clipped on terminals. That will be fine as you'll
140    /// want your widths to be at least 1 longer than the text itself,
141    /// anyway. `widths` must include the spacing between the
142    /// columns--i.e. make it 2-3 larger than the max. expected width
143    /// of the data.
144    pub fn new(widths: &[usize], opts: TerminalTableOpts, out: O) -> Self {
145        let max_width = widths.iter().max().copied().unwrap_or(0);
146        let padding = " ".repeat(max_width);
147        let is_terminal = out.is_terminal();
148
149        let width = get_terminal_width(1);
150        let bar_of = |c: &str| c.repeat(width) + "\n";
151        let (thin_bar, thick_bar) = if *UNICODE_IS_FINE {
152            (bar_of("─"), bar_of("═"))
153        } else {
154            (bar_of("-"), bar_of("="))
155        };
156
157        Self {
158            settings: TerminalTableSettings {
159                widths: widths.to_owned(),
160                padding,
161                is_terminal,
162            },
163            opts,
164            out: BufWriter::new(out),
165            thin_bar,
166            thick_bar,
167        }
168    }
169}
170
171impl<O: Write + IsTerminal> OutputTable for TerminalTable<O> {
172    type Output = O;
173
174    fn num_columns(&self) -> usize {
175        self.settings.widths.len() + 1
176    }
177
178    // Not making this an instance method so that we can give mut vs
179    // non-mut parts independently
180    fn write_row<'url, V: CellValue<'url>>(
181        &mut self,
182        row: Row<V>,
183        line_style: Option<OutputStyle>,
184    ) -> Result<()> {
185        let (expected_num_columns, row_num_columns) = (self.num_columns(), row.logical_len());
186        if expected_num_columns != row_num_columns {
187            bail!(
188                "the row contains {row_num_columns} instead of the expected \
189                 {expected_num_columns} columns"
190            )
191        }
192
193        let mut is_first = true;
194        for (text, width_opt) in row.string_and_widths(&self.settings.widths) {
195            if self.opts.tsv && !is_first {
196                self.out.write_all("\t".as_bytes())?;
197            }
198            let mut text = text.to_string();
199            let minimal_pading_len;
200            if let Some(style) = line_style {
201                // make sure italic text is not clipped on terminals
202                text.push_str(" ");
203                minimal_pading_len = Self::MINIMAL_PADDING_LEN.saturating_sub(1);
204                let s = text.as_str().paint(style);
205                let s = s.to_string();
206                self.out.write_all(s.as_bytes())?;
207            } else {
208                minimal_pading_len = Self::MINIMAL_PADDING_LEN;
209                self.out.write_all(text.as_bytes())?;
210            }
211            let text_len = text.len();
212
213            if let Some(width) = width_opt {
214                if !self.opts.tsv {
215                    let missing_padding_len = width.saturating_sub(text_len);
216                    let wanted_padding_len = missing_padding_len.max(minimal_pading_len);
217                    let padding = &self.settings.padding[0..wanted_padding_len];
218                    self.out.write_all(padding.as_bytes())?;
219                }
220            }
221
222            is_first = false;
223        }
224        self.out.write_all(b"\n")?;
225        Ok(())
226    }
227
228    fn write_title_row(
229        &mut self,
230        titles: &[OutputTableTitle],
231        line_style: Option<OutputStyle>,
232    ) -> Result<()> {
233        let style = line_style.unwrap_or(OutputStyle {
234            bold: true,
235            italic: true,
236            ..Default::default()
237        });
238
239        self.write_row(
240            Row::<&str>::WithSpans(titles),
241            if self.opts.want_color(self.settings.is_terminal) {
242                Some(style)
243            } else {
244                None
245            },
246        )
247    }
248
249    fn write_bar(&mut self, bar_kind: BarKind, _anchor_name: Option<&str>) -> anyhow::Result<()> {
250        let bar = match bar_kind {
251            BarKind::Thin => &self.thin_bar,
252            BarKind::Thick => &self.thick_bar,
253        };
254        Ok(self.out.write_all(bar.as_bytes())?)
255    }
256
257    fn print<'url, V: CellValue<'url>>(&mut self, value: V) -> anyhow::Result<()> {
258        self.out.write_all(value.as_ref().as_bytes())?;
259        Ok(())
260    }
261
262    fn finish(self) -> Result<O> {
263        self.out
264            .into_inner()
265            .map_err(|e| anyhow!("flushing the buffer: {}", e.error()))
266    }
267}