1use 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#[derive(Clone, Copy)]
33pub enum Paint {
34 Fixed(Style),
35 ByValue(fn(&str) -> Style),
36 ByOther {
43 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#[derive(Debug, Clone, Copy)]
77pub struct Column {
78 pub header: &'static str,
79 pub width: usize,
81 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 #[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 #[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 #[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#[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
149fn 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
184fn 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 table.with(
209 Width::truncate(ctx.width)
210 .suffix("…")
211 .priority(Priority::max(true)),
212 );
213
214 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#[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#[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#[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 #[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 #[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 #[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 #[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}