Skip to main content

ytcli/render/
table.rs

1//! Listings, in the two shapes their two readers need.
2//!
3//! A row is built once and formatted twice. That is the whole point of this
4//! module: the data a terminal shows and the data a pipe shows are the same
5//! values in the same order, and only the arrangement differs (ADR 3).
6//!
7//! **A pipe gets fixed-width columns.** Every run of a command produces the same
8//! byte offsets whatever the window is and whatever the rows contain, which is
9//! what makes the output safe to `cut`, to diff, and to cache.
10//!
11//! **A terminal gets a table sized to its contents**, because a person is not
12//! parsing byte offsets and a column padded to a width nothing in it uses is
13//! just wasted screen. Columns shrink to fit the window, widest first, so the
14//! keys stay readable when the summaries do not fit.
15
16use std::fmt::Write as _;
17
18use anstyle::Style;
19use tabled::builder::Builder;
20use tabled::settings::peaker::Priority;
21use tabled::settings::{Padding, Width};
22
23use crate::render::Context;
24use crate::render::style::{Painter, Palette};
25
26/// How a column's cells are painted.
27///
28/// Some columns say something by their value rather than by their position — a
29/// field that is custom rather than system is the reason to run the command that
30/// lists it — so the style can depend on the cell. Only a terminal ever sees the
31/// difference; a pipe is never painted at all.
32#[derive(Clone, Copy)]
33pub enum Paint {
34    Fixed(Style),
35    ByValue(fn(&str) -> Style),
36    /// Painted from a value the row carries but does not print.
37    ///
38    /// A status is shown in the organisation's own language and classified by
39    /// the key Tracker keeps behind it: `Закрыт` is worth a colour only if
40    /// something knows it means `closed`. Guessing from the displayed words
41    /// works in English and nowhere else.
42    ByOther {
43        /// Index into the row, past the end of the columns.
44        source: usize,
45        pick: fn(&str) -> Style,
46    },
47}
48
49impl std::fmt::Debug for Paint {
50    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
51        match self {
52            Self::Fixed(style) => f.debug_tuple("Fixed").field(style).finish(),
53            Self::ByValue(_) => f.write_str("ByValue(..)"),
54            Self::ByOther { source, .. } => write!(f, "ByOther({source})"),
55        }
56    }
57}
58
59impl Paint {
60    fn style(self, cell: &str, row: &[String]) -> Style {
61        match self {
62            Self::Fixed(style) => style,
63            Self::ByValue(pick) => pick(cell),
64            Self::ByOther { source, pick } => row.get(source).map_or_else(Style::new, |value| {
65                if value.is_empty() {
66                    Style::new()
67                } else {
68                    pick(value)
69                }
70            }),
71        }
72    }
73}
74
75/// One column: how a pipe lays it out, and how it is painted.
76#[derive(Debug, Clone, Copy)]
77pub struct Column {
78    pub header: &'static str,
79    /// Width a pipe pads or cuts this column to.
80    pub width: usize,
81    /// Cut a value that is too long. A key is never cut — a truncated
82    /// identifier is not an identifier.
83    pub truncate: bool,
84    pub paint: Paint,
85}
86
87impl Column {
88    #[must_use]
89    pub const fn new(header: &'static str, width: usize, style: Style) -> Self {
90        Self {
91            header,
92            width,
93            truncate: true,
94            paint: Paint::Fixed(style),
95        }
96    }
97
98    /// A column whose values are never cut.
99    #[must_use]
100    pub const fn whole(header: &'static str, width: usize, style: Style) -> Self {
101        Self {
102            truncate: false,
103            ..Self::new(header, width, style)
104        }
105    }
106
107    /// A column painted from its own value.
108    #[must_use]
109    pub const fn by_value(header: &'static str, width: usize, pick: fn(&str) -> Style) -> Self {
110        Self {
111            header,
112            width,
113            truncate: true,
114            paint: Paint::ByValue(pick),
115        }
116    }
117
118    /// A column painted from a value the row carries after its last column.
119    ///
120    /// Those trailing values are never printed — neither format shows more
121    /// cells than there are columns — so what a caller receives is unchanged
122    /// and only the colour knows about them.
123    #[must_use]
124    pub const fn by_other(
125        header: &'static str,
126        width: usize,
127        source: usize,
128        pick: fn(&str) -> Style,
129    ) -> Self {
130        Self {
131            header,
132            width,
133            truncate: true,
134            paint: Paint::ByOther { source, pick },
135        }
136    }
137}
138
139/// Render rows as a listing, without the tally that follows them.
140#[must_use]
141pub fn render(columns: &[Column], rows: &[Vec<String>], ctx: &Context) -> String {
142    if ctx.is_human() {
143        human(columns, rows, ctx)
144    } else {
145        machine(columns, rows)
146    }
147}
148
149/// Fixed-width columns, separated by one space.
150///
151/// The last column is never padded: trailing spaces are invisible until
152/// something copies them.
153fn machine(columns: &[Column], rows: &[Vec<String>]) -> String {
154    let mut out = String::with_capacity(rows.len() * 80);
155
156    for row in rows {
157        let mut line = String::with_capacity(80);
158        let printed = row.len().min(columns.len());
159        for (index, cell) in row.iter().take(printed).enumerate() {
160            let Some(column) = columns.get(index) else {
161                continue;
162            };
163            let value = if column.truncate {
164                truncate(cell, column.width)
165            } else {
166                cell.clone()
167            };
168            if index + 1 == printed {
169                line.push_str(&value);
170            } else {
171                let _ = write!(
172                    line,
173                    "{value}{} ",
174                    " ".repeat(column.width.saturating_sub(value.chars().count()))
175                );
176            }
177        }
178        let _ = writeln!(out, "{}", line.trim_end());
179    }
180
181    out
182}
183
184/// A table sized to its contents, shrunk to the window if it does not fit.
185fn human(columns: &[Column], rows: &[Vec<String>], ctx: &Context) -> String {
186    if rows.is_empty() {
187        return String::new();
188    }
189    let paint = ctx.painter();
190
191    let mut builder = Builder::with_capacity(rows.len() + 1, columns.len());
192    builder.push_record(
193        columns
194            .iter()
195            .map(|column| paint.paint(column.header, Palette::label())),
196    );
197    for row in rows {
198        builder.push_record(paint_row(columns, row, paint));
199    }
200
201    let mut table = builder.build();
202    table
203        .with(tabled::settings::Style::blank())
204        .with(Padding::new(0, 2, 0, 0));
205
206    // Shrink the widest columns first: a cut summary is still useful, a cut key
207    // is not.
208    table.with(
209        Width::truncate(ctx.width)
210            .suffix("…")
211            .priority(Priority::max(true)),
212    );
213
214    // tabled pads the last column out to the table width; those spaces are
215    // invisible until something copies them.
216    let mut out = String::with_capacity(rows.len() * 96);
217    for line in table.to_string().lines() {
218        let _ = writeln!(out, "{}", line.trim_end());
219    }
220    out
221}
222
223fn paint_row(columns: &[Column], row: &[String], paint: Painter) -> Vec<String> {
224    row.iter()
225        .take(columns.len())
226        .enumerate()
227        .map(|(index, cell)| match columns.get(index) {
228            Some(column) => paint.paint(cell, column.paint.style(cell, row)),
229            None => cell.clone(),
230        })
231        .collect()
232}
233
234/// The `shown N of M` line every listing ends with, and the next page when one
235/// exists.
236///
237/// Never optional. A caller that receives 25 rows and cannot tell a complete
238/// answer from a truncated one will eventually conclude there is nothing to
239/// find, which is a worse failure than any number of wasted tokens.
240#[must_use]
241pub fn tally(shown: usize, total: Option<u64>, next_page: Option<u32>, ctx: &Context) -> String {
242    let paint = ctx.painter();
243    let counted = match total {
244        Some(total) => format!("shown {shown} of {total}"),
245        None => format!("shown {shown} of unknown total"),
246    };
247
248    let mut out = paint.paint(&counted, Palette::label());
249    if let Some(page) = next_page {
250        out.push_str(&paint.paint(&format!(" — next: --page {page}"), Palette::warn()));
251    }
252    out.push('\n');
253    out
254}
255
256pub(crate) fn truncate(value: &str, width: usize) -> String {
257    if value.chars().count() <= width {
258        return value.to_owned();
259    }
260    let mut kept: String = value.chars().take(width.saturating_sub(1)).collect();
261    kept.push('…');
262    kept
263}
264
265#[cfg(test)]
266mod tests {
267    use super::*;
268    use crate::render::{Audience, Format};
269
270    fn ctx(audience: Audience) -> Context {
271        Context {
272            format: Format::Text,
273            audience,
274            description_lines: None,
275            extra_fields: Vec::new(),
276            width: 80,
277            images: false,
278            inline: crate::render::image::Inline::default(),
279        }
280    }
281
282    fn columns() -> Vec<Column> {
283        vec![
284            Column::whole("KEY", 12, Palette::key()),
285            Column::new("SUMMARY", 40, Style::new()),
286        ]
287    }
288
289    fn rows() -> Vec<Vec<String>> {
290        vec![
291            vec!["PROJ-1".to_owned(), "short".to_owned()],
292            vec!["PROJ-22".to_owned(), "a longer summary".to_owned()],
293        ]
294    }
295
296    /// The promise of the fixed-width form: a column starts at the same offset
297    /// on every row, whatever the row contains.
298    #[test]
299    fn a_pipe_puts_every_column_at_a_fixed_offset() {
300        let out = machine(&columns(), &rows());
301        for (line, row) in out.lines().zip(rows()) {
302            let summary: String = line.chars().skip(13).collect();
303            assert_eq!(summary, row[1], "the second column moved");
304        }
305    }
306
307    #[test]
308    fn a_pipe_gets_no_trailing_padding() {
309        let out = machine(&columns(), &rows());
310        assert!(out.lines().all(|line| !line.ends_with(' ')));
311    }
312
313    /// The rule that makes two renderings of one row safe: same values, same
314    /// order, whatever the decoration.
315    #[test]
316    fn both_forms_carry_the_same_values() {
317        let piped = machine(&columns(), &rows());
318        let terminal = human(&columns(), &rows(), &ctx(Audience::Human));
319
320        for row in rows() {
321            for cell in row {
322                assert!(piped.contains(&cell), "{cell} missing from the pipe form");
323                assert!(
324                    terminal.contains(&cell),
325                    "{cell} missing from the terminal form"
326                );
327            }
328        }
329    }
330
331    /// A key is an identifier a caller types back. Cutting one produces
332    /// something that looks like a key and is not.
333    #[test]
334    fn a_key_is_never_cut() {
335        let long = vec![vec!["PROJECT-1234567890".to_owned(), "summary".to_owned()]];
336        assert!(machine(&columns(), &long).contains("PROJECT-1234567890"));
337    }
338
339    #[test]
340    fn an_over_long_value_is_cut_with_an_ellipsis() {
341        assert_eq!(truncate("abcdef", 4), "abc…");
342        assert_eq!(truncate("abc", 4), "abc");
343    }
344
345    #[test]
346    fn a_terminal_table_stays_inside_the_window() {
347        let wide = vec![vec!["PROJ-1".to_owned(), "x".repeat(400)]];
348        let narrow = Context {
349            width: 40,
350            ..ctx(Audience::Human)
351        };
352        let out = human(&columns(), &wide, &narrow);
353        assert!(
354            out.lines()
355                .all(|line| strip_ansi(line).chars().count() <= 40),
356            "a line ran past the window"
357        );
358    }
359
360    fn strip_ansi(text: &str) -> String {
361        let mut out = String::with_capacity(text.len());
362        let mut chars = text.chars();
363        while let Some(c) = chars.next() {
364            if c != '\u{1b}' {
365                out.push(c);
366                continue;
367            }
368            for c in chars.by_ref() {
369                if c.is_ascii_alphabetic() {
370                    break;
371                }
372            }
373        }
374        out
375    }
376
377    #[test]
378    fn the_tally_names_the_next_page_when_there_is_one() {
379        let ctx = ctx(Audience::Machine);
380        assert_eq!(
381            tally(25, Some(340), Some(2), &ctx),
382            "shown 25 of 340 — next: --page 2\n"
383        );
384        assert_eq!(tally(1, Some(1), None, &ctx), "shown 1 of 1\n");
385        assert_eq!(tally(1, None, None, &ctx), "shown 1 of unknown total\n");
386    }
387}