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
256/// The tally for a listing that pages by cursor and never reports a total —
257/// every Wiki listing (`docs/adr/0007-yandex-wiki.md`).
258///
259/// "Of more than N" while a next page exists: the count cannot be printed
260/// honestly, but the rule the tally serves — never pass one page off as all of
261/// them — still can be kept.
262#[must_use]
263pub fn cursor_tally(shown: usize, next: Option<&str>, ctx: &Context) -> String {
264    open_tally(shown, next.map(|cursor| format!("--cursor {cursor}")), ctx)
265}
266
267/// [`cursor_tally`] for the one Wiki listing that pages by number: search.
268#[must_use]
269pub fn open_page_tally(shown: usize, next_page: Option<u32>, ctx: &Context) -> String {
270    open_tally(shown, next_page.map(|page| format!("--page {page}")), ctx)
271}
272
273fn open_tally(shown: usize, next: Option<String>, ctx: &Context) -> String {
274    let paint = ctx.painter();
275    let counted = match next {
276        Some(_) => format!("shown {shown} of more than {shown}"),
277        None => format!("shown {shown} of {shown}"),
278    };
279
280    let mut out = paint.paint(&counted, Palette::label());
281    if let Some(next) = next {
282        out.push_str(&paint.paint(&format!(" — next: {next}"), Palette::warn()));
283    }
284    out.push('\n');
285    out
286}
287
288pub(crate) fn truncate(value: &str, width: usize) -> String {
289    if value.chars().count() <= width {
290        return value.to_owned();
291    }
292    let mut kept: String = value.chars().take(width.saturating_sub(1)).collect();
293    kept.push('…');
294    kept
295}
296
297#[cfg(test)]
298mod tests {
299    use super::*;
300    use crate::render::{Audience, Format};
301
302    fn ctx(audience: Audience) -> Context {
303        Context {
304            format: Format::Text,
305            audience,
306            description_lines: None,
307            extra_fields: Vec::new(),
308            width: 80,
309            images: false,
310            inline: crate::render::image::Inline::default(),
311        }
312    }
313
314    /// No total, so the only claim made is the true one: there is more.
315    #[test]
316    fn a_cursor_tally_with_more_to_come_says_so_and_names_the_cursor() {
317        assert_eq!(
318            cursor_tally(50, Some("eyJpZCI6NH0="), &ctx(Audience::Machine)),
319            "shown 50 of more than 50 — next: --cursor eyJpZCI6NH0=\n"
320        );
321    }
322
323    #[test]
324    fn a_cursor_tally_on_the_last_page_is_complete() {
325        assert_eq!(
326            cursor_tally(2, None, &ctx(Audience::Machine)),
327            "shown 2 of 2\n"
328        );
329    }
330
331    fn columns() -> Vec<Column> {
332        vec![
333            Column::whole("KEY", 12, Palette::key()),
334            Column::new("SUMMARY", 40, Style::new()),
335        ]
336    }
337
338    fn rows() -> Vec<Vec<String>> {
339        vec![
340            vec!["PROJ-1".to_owned(), "short".to_owned()],
341            vec!["PROJ-22".to_owned(), "a longer summary".to_owned()],
342        ]
343    }
344
345    /// The promise of the fixed-width form: a column starts at the same offset
346    /// on every row, whatever the row contains.
347    #[test]
348    fn a_pipe_puts_every_column_at_a_fixed_offset() {
349        let out = machine(&columns(), &rows());
350        for (line, row) in out.lines().zip(rows()) {
351            let summary: String = line.chars().skip(13).collect();
352            assert_eq!(summary, row[1], "the second column moved");
353        }
354    }
355
356    #[test]
357    fn a_pipe_gets_no_trailing_padding() {
358        let out = machine(&columns(), &rows());
359        assert!(out.lines().all(|line| !line.ends_with(' ')));
360    }
361
362    /// The rule that makes two renderings of one row safe: same values, same
363    /// order, whatever the decoration.
364    #[test]
365    fn both_forms_carry_the_same_values() {
366        let piped = machine(&columns(), &rows());
367        let terminal = human(&columns(), &rows(), &ctx(Audience::Human));
368
369        for row in rows() {
370            for cell in row {
371                assert!(piped.contains(&cell), "{cell} missing from the pipe form");
372                assert!(
373                    terminal.contains(&cell),
374                    "{cell} missing from the terminal form"
375                );
376            }
377        }
378    }
379
380    /// A key is an identifier a caller types back. Cutting one produces
381    /// something that looks like a key and is not.
382    #[test]
383    fn a_key_is_never_cut() {
384        let long = vec![vec!["PROJECT-1234567890".to_owned(), "summary".to_owned()]];
385        assert!(machine(&columns(), &long).contains("PROJECT-1234567890"));
386    }
387
388    #[test]
389    fn an_over_long_value_is_cut_with_an_ellipsis() {
390        assert_eq!(truncate("abcdef", 4), "abc…");
391        assert_eq!(truncate("abc", 4), "abc");
392    }
393
394    #[test]
395    fn a_terminal_table_stays_inside_the_window() {
396        let wide = vec![vec!["PROJ-1".to_owned(), "x".repeat(400)]];
397        let narrow = Context {
398            width: 40,
399            ..ctx(Audience::Human)
400        };
401        let out = human(&columns(), &wide, &narrow);
402        assert!(
403            out.lines()
404                .all(|line| strip_ansi(line).chars().count() <= 40),
405            "a line ran past the window"
406        );
407    }
408
409    fn strip_ansi(text: &str) -> String {
410        let mut out = String::with_capacity(text.len());
411        let mut chars = text.chars();
412        while let Some(c) = chars.next() {
413            if c != '\u{1b}' {
414                out.push(c);
415                continue;
416            }
417            for c in chars.by_ref() {
418                if c.is_ascii_alphabetic() {
419                    break;
420                }
421            }
422        }
423        out
424    }
425
426    #[test]
427    fn the_tally_names_the_next_page_when_there_is_one() {
428        let ctx = ctx(Audience::Machine);
429        assert_eq!(
430            tally(25, Some(340), Some(2), &ctx),
431            "shown 25 of 340 — next: --page 2\n"
432        );
433        assert_eq!(tally(1, Some(1), None, &ctx), "shown 1 of 1\n");
434        assert_eq!(tally(1, None, None, &ctx), "shown 1 of unknown total\n");
435    }
436}