qpprint

Check-in Differences
Login

Check-in Differences

Many hyperlinks are disabled.
Use anonymous login to enable hyperlinks.

Difference From qpprint-0.3.1 To trunk

2025-05-30
14:43
Begin brainstorming a redesign using a trait for renderer instead of closures. Leaf check-in: bb24ce375f user: jan tags: renderer-trait
14:26
Pass colmeta to callbacks. Leaf check-in: 6db8cf1b39 user: jan tags: trunk
13:28
Up version and changelog update. check-in: edbbe960cd user: jan tags: trunk
12:54
Merge meta. check-in: 7e55bb9041 user: jan tags: trunk
12:09
Merge from trunk to get latest unicode width updates. check-in: 8551ed53a3 user: jan tags: meta
11:13
Release maintenance. check-in: c838aa0bea user: jan tags: qpprint-0.3.1, trunk
11:07
Merge. check-in: be1d708848 user: jan tags: trunk

Changes to Cargo.toml.
1
2
3

4
5
6
7
8
9
10
1
2

3
4
5
6
7
8
9
10


-
+







[package]
name = "qpprint"
version = "0.3.1"
version = "0.4.0"
edition = "2024"
license = "0BSD"
# https://crates.io/category_slugs
categories = [ "text-processing" ]
keywords = [ "cli", "console", "terminal", "format", "print" ]
repository = "https://repos.qrnch.tech/pub/qpprint"
description = "Simple console printing/formatting."
Changes to examples/newtbl.rs.
1

2
3
4
5
6
7
8
9
10
11
12


13
14
15
16
17
18
19
20
21
22


23
24
25
26
27
28
29

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22


23
24
25
26
27
28
29
30
31
-
+











+
+








-
-
+
+







use qpprint::tbl::{Align, CellValue, Column, Data, Renderer};
use qpprint::tbl::{Align, CellData, CellValue, Column, Data, Renderer};

use yansi::{Color, Painted};

fn main() {
  table1();
  println!();
  table2();
  println!();
  table3();
  println!();
  table4();
  println!();
  table5();
}


fn table1() {
  //
  // Define table columns
  //
  let columns = [
    Column::new("Id", ToString::to_string),
    Column::new("Name", ToString::to_string)
    Column::<(), ()>::new("Id", |_cm, cd| cd.to_string()),
    Column::<(), ()>::new("Name", |_cm, cd| cd.to_string())
  ];

  //
  // Create raw table data representation, with the number of columns defined
  // by `columns`.
  //
  let mut data = Data::new(columns.len());
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74























75
76
77
78
79


80
81
82
83
84
85
86

87
88


89
90
91

92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110


111
112
113
114
115
116
117



















































118
119
120
121
122
123
124
50
51
52
53
54
55
56




















57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82


83
84
85
86
87
88
89
90
91
92


93
94
95
96

97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114


115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181







-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+



-
-
+
+







+
-
-
+
+


-
+

















-
-
+
+







+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+







  renderer.print();
}


/// Print `Id` as blue if it is greater than `20`.  Print `Name` as red if it
/// is equal to `world`.
fn table2() {
  let idcol = Column::new("Id", ToString::to_string).stylize(|cv, s| {
    let cs = Painted::new(s.to_string());
    if let CellValue::U64(id) = cv {
      if *id > 20 {
        cs.fg(Color::Blue)
      } else {
        cs
      }
    } else {
      cs
    }
  });
  let namecol = Column::new("Name", ToString::to_string).stylize(|_cv, s| {
    let cs = Painted::new(s.to_string());
    if s == "world" {
      cs.fg(Color::Red)
    } else {
      cs
    }
  });
  let idcol = Column::<(), ()>::new("Id", |_cm, cd| cd.to_string()).stylize(
    |_cm, cd, s| {
      let cs = Painted::new(s.to_string());
      if let CellValue::U64(id) = cd.val {
        if id > 20 {
          cs.fg(Color::Blue)
        } else {
          cs
        }
      } else {
        cs
      }
    }
  );
  let namecol =
    Column::new("Name", |_cm, cd| cd.to_string()).stylize(|_cm, _cv, s| {
      let cs = Painted::new(s.to_string());
      if s == "world" {
        cs.fg(Color::Red)
      } else {
        cs
      }
    });
  let columns = [idcol, namecol];

  let mut data = Data::new(columns.len());
  data.add_row(vec![CellValue::U64(42), CellValue::Str("hello".into())]);
  data.add_row(vec![CellValue::U64(11), CellValue::Str("world".into())]);
  data.add_row(vec![CellData::u64(42), CellData::str("hello")]);
  data.add_row(vec![CellData::u64(11), CellData::str("world")]);

  let renderer = Renderer::new(&columns, &data).header(Some('-'));

  renderer.print();
}

fn table3() {
  let idcol = Column::<(), ()>::new("Id", |_cm, cd| cd.to_string())
  let idcol = Column::new("Id", ToString::to_string).cell_align(Align::Right);
  let namecol = Column::new("Name", ToString::to_string)
    .cell_align(Align::Right);
  let namecol = Column::new("Name", |_cm, cd| cd.to_string())
    .max_width(4)
    .trunc_style(2, '.');
  let namecol2 = Column::new("Title", ToString::to_string)
  let namecol2 = Column::new("Title", |_cm, cd| cd.to_string())
    .max_width(4)
    .trunc_style(1, '…');

  let columns = [idcol, namecol, namecol2];

  let mut data = Data::new(columns.len());
  data.add_row(vec![4.into(), "hell".into(), "hell".into()]);
  data.add_row(vec![42.into(), "hello".into(), "hello".into()]);
  data.add_row(vec![11.into(), "world".into(), "world".into()]);

  let renderer = Renderer::new(&columns, &data).header(Some('='));

  renderer.print();
}


fn table4() {
  let keycol = Column::new("Key", ToString::to_string);
  let valcol = Column::new("Value", ToString::to_string);
  let keycol = Column::<(), ()>::new("Key", |_cm, cd| cd.to_string());
  let valcol = Column::new("Value", |_cm, cd| cd.to_string());

  let columns = [keycol, valcol];

  let mut data = Data::new(columns.len());
  data.add_row(vec!["hell".into(), "hell".into()]);
  data.add_row(vec!["hello🔒".into(), "hello".into()]);
  data.add_row(vec!["world".into(), "world".into()]);

  let renderer = Renderer::new(&columns, &data).header(Some('='));

  renderer.print();
}


struct ColMeta {}

struct CellMeta {
  default: String
}

fn table5() {
  let keycol =
    Column::<ColMeta, CellMeta>::new("Key", |_cm, cd| cd.to_string());
  let valcol =
    Column::<ColMeta, CellMeta>::new("Value", |_cm, cd| cd.to_string())
      .stylize(|_cm, cd, s| {
        let cs = Painted::new(s.to_string());
        if let Some(ref meta) = cd.meta {
          if meta.default == s {
            // cell value == cell meta default
            cs.fg(Color::BrightBlue)
          } else {
            cs
          }
        } else {
          cs
        }
      });

  let columns = [keycol, valcol];

  let mut data = Data::new(columns.len());

  let cd = CellData {
    val: "some value".into(),
    meta: Some(CellMeta {
      default: "another value".into()
    })
  };
  data.add_row(vec!["foo".into(), cd]);

  let cd = CellData {
    val: "another value".into(),
    meta: Some(CellMeta {
      default: "another value".into()
    })
  };
  data.add_row(vec!["bar".into(), cd]);

  let renderer = Renderer::new(&columns, &data).header(Some('='));

  renderer.print();
}

// vim: set ft=rust et sw=2 ts=2 sts=2 cinoptions=2 tw=79 :
Changes to src/tbl.rs.
8
9
10
11
12
13
14
15

16
17
18
19
20
21
22



23
24


25
26
27

28
29
30
31

32
33
34
35
36
37
38
39
40
41
42


43
44
45
46
47
48
49
8
9
10
11
12
13
14

15
16
17
18
19
20


21
22
23
24

25
26
27
28

29
30
31
32

33
34
35
36
37
38
39
40
41
42
43

44
45
46
47
48
49
50
51
52







-
+





-
-
+
+
+

-
+
+


-
+



-
+










-
+
+








use yansi::Painted;

pub use super::Align;


#[allow(clippy::type_complexity)]
pub struct Column {
pub struct Column<CM, CDM> {
  title: String,
  min_width: Option<usize>,
  max_width: Option<usize>,
  trunc_len: usize,
  trunc_ch: char,
  renderer: Box<dyn Fn(&CellValue) -> String>,
  stylize: Option<Box<dyn Fn(&CellValue, &str) -> Painted<String>>>,
  renderer: Box<dyn Fn(Option<&CM>, &CellData<CDM>) -> String>,
  stylize:
    Option<Box<dyn Fn(Option<&CM>, &CellData<CDM>, &str) -> Painted<String>>>,
  title_align: Align,
  cell_align: Align
  cell_align: Align,
  colmeta: Option<CM>
}

impl Column {
impl<CM, CDM> Column<CM, CDM> {
  #[allow(clippy::needless_pass_by_value)]
  pub fn new(
    heading: impl ToString,
    renderer: impl Fn(&CellValue) -> String + 'static
    renderer: impl Fn(Option<&CM>, &CellData<CDM>) -> String + 'static
  ) -> Self {
    Self {
      title: heading.to_string(),
      min_width: None,
      max_width: None,
      trunc_len: 0,
      trunc_ch: '…',
      renderer: Box::new(renderer),
      stylize: None,
      title_align: Align::Center,
      cell_align: Align::Left
      cell_align: Align::Left,
      colmeta: None
    }
  }

  #[must_use]
  pub fn min_width(mut self, min: usize) -> Self {
    self.min_width_ref(min);
    self
89
90
91
92
93
94
95
96

97
98
99
100
101
102
103
92
93
94
95
96
97
98

99
100
101
102
103
104
105
106







-
+







    self.trunc_ch = ch;
    self
  }

  #[must_use]
  pub fn stylize(
    mut self,
    f: impl Fn(&CellValue, &str) -> Painted<String> + 'static
    f: impl Fn(Option<&CM>, &CellData<CDM>, &str) -> Painted<String> + 'static
  ) -> Self {
    self.stylize = Some(Box::new(f));
    self
  }

  #[must_use]
  pub const fn title_align(mut self, align: Align) -> Self {
116
117
118
119
120
121
122











123
124
125
126
127
128
129
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143







+
+
+
+
+
+
+
+
+
+
+







    self
  }

  pub const fn cell_align_ref(&mut self, align: Align) -> &mut Self {
    self.cell_align = align;
    self
  }

  #[must_use]
  pub fn meta(mut self, m: CM) -> Self {
    self.colmeta = Some(m);
    self
  }

  pub fn meta_r(&mut self, m: CM) -> &mut Self {
    self.colmeta = Some(m);
    self
  }
}


pub enum CellValue {
  Str(String),
  U64(u64)
}
152
153
154
155
156
157
158



































































159

160
161

162
163
164

165
166
167
168
169
170
171
172
173
174
175

176
177
178
179
180
181
182
183
184
185
186
187
188

189
190
191
192
193


194
195
196

197
198

199
200
201
202
203
204
205
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239

240
241

242
243
244

245
246
247
248
249
250
251
252
253
254
255

256
257
258
259
260
261
262
263
264
265
266
267
268

269
270
271
272


273
274
275
276

277
278

279
280
281
282
283
284
285
286







+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
+

-
+


-
+










-
+












-
+



-
-
+
+


-
+

-
+







      Self::Str(s) => write!(f, "{s}"),
      Self::U64(v) => write!(f, "{v}")
    }
  }
}


pub struct CellData<CDM> {
  pub val: CellValue,
  pub meta: Option<CDM>
}

impl<CDM> CellData<CDM> {
  #[must_use]
  pub fn str(val: impl Into<String>) -> Self {
    Self {
      val: CellValue::Str(val.into()),
      meta: None
    }
  }

  #[must_use]
  pub const fn u64(val: u64) -> Self {
    Self {
      val: CellValue::U64(val),
      meta: None
    }
  }

  #[must_use]
  pub fn meta(mut self, md: CDM) -> Self {
    self.meta = Some(md);
    self
  }
}

impl<CDM> From<String> for CellData<CDM> {
  fn from(val: String) -> Self {
    Self {
      val: CellValue::from(val),
      meta: None
    }
  }
}

impl<CDM> From<&str> for CellData<CDM> {
  fn from(val: &str) -> Self {
    Self {
      val: CellValue::from(val),
      meta: None
    }
  }
}

impl<CDM> From<u64> for CellData<CDM> {
  fn from(val: u64) -> Self {
    Self {
      val: CellValue::from(val),
      meta: None
    }
  }
}

impl<CDM> fmt::Display for CellData<CDM> {
  fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
    match &self.val {
      CellValue::Str(s) => write!(f, "{s}"),
      CellValue::U64(v) => write!(f, "{v}")
    }
  }
}


// Rename to TableData?
pub struct Data {
pub struct Data<CDM> {
  num_cols: usize,
  cells: Vec<Vec<CellValue>>
  cells: Vec<Vec<CellData<CDM>>>
}

impl Data {
impl<CDM> Data<CDM> {
  #[must_use]
  pub const fn new(cols: usize) -> Self {
    Self {
      num_cols: cols,
      cells: Vec::new()
    }
  }

  /// # Panics
  /// The row length must equal to the number of columns.
  pub fn add_row(&mut self, row: Vec<CellValue>) {
  pub fn add_row(&mut self, row: Vec<CellData<CDM>>) {
    assert_eq!(row.len(), self.num_cols);
    self.cells.push(row);
  }
}

pub trait CellRender {
  fn stringify();

  fn print();
}


pub struct Renderer<'a> {
pub struct Renderer<'a, CM, CDM> {
  show_header: bool,
  header_underline: Option<char>,
  col_spacing: usize,
  cols: &'a [Column],
  data: &'a [Vec<CellValue>]
  cols: &'a [Column<CM, CDM>],
  data: &'a [Vec<CellData<CDM>>]
}

impl<'a> Renderer<'a> {
impl<'a, CM, CDM> Renderer<'a, CM, CDM> {
  #[must_use]
  pub fn new(cols: &'a [Column], data: &'a Data) -> Self {
  pub fn new(cols: &'a [Column<CM, CDM>], data: &'a Data<CDM>) -> Self {
    Self {
      show_header: false,
      header_underline: None,
      col_spacing: 2,
      cols,
      data: &data.cells
    }
240
241
242
243
244
245
246
247

248
249
250
251
252
253
254
321
322
323
324
325
326
327

328
329
330
331
332
333
334
335







-
+







/// long as no one tries to be too creative.
#[inline]
fn strlen(s: &str) -> usize {
  //c.title.chars().count()
  s.width()
}

impl fmt::Display for Renderer<'_> {
impl<CM, CDM> fmt::Display for Renderer<'_, CM, CDM> {
  fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
    //
    // Iterate over cells and generate a new rendered table.
    // Every cell here is a String
    //
    let mut rendered: Vec<Vec<String>> = Vec::with_capacity(self.data.len());

286
287
288
289
290
291
292
293

294
295
296
297
298
299
300
367
368
369
370
371
372
373

374
375
376
377
378
379
380
381







-
+







        rrow.push(cell_str);
      }
      */

      for (cw, (col, cell)) in
        col_widths.iter_mut().zip(iter::zip(self.cols, row))
      {
        let cell_str = (col.renderer)(cell);
        let cell_str = (col.renderer)(col.colmeta.as_ref(), cell);

        // ToDo: Cut down to size if cell_str.len() exceeds column's max_width

        //if cell_str.len() > col_width {}

        *cw = std::cmp::max(*cw, strlen(&cell_str));
        rrow.push(cell_str);
351
352
353
354
355
356
357
358

359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
432
433
434
435
436
437
438

439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456

457
458
459
460
461
462
463







-
+

















-








      for ((col, cw), (cv, cell)) in
        iter::zip(iter::zip(self.cols, &col_widths), iter::zip(cvs, row))
      {
        let cell = trunc_str(&cell, *cw, col.trunc_len, col.trunc_ch);

        if let Some(ref stylize) = col.stylize {
          let cell = stylize(cv, &cell);
          let cell = stylize(col.colmeta.as_ref(), cv, &cell);
          let cc = format_painted_cell(&cell, *cw, col.cell_align);
          fields.push(cc);
        } else {
          let cc = format_cell(&cell, *cw, col.cell_align);
          fields.push(cc);
        }
      }

      writeln!(f, "{}", fields.join(&colspace))?;
    }
    Ok(())
  }
}

fn format_cell(s: &str, cell_width: usize, align: Align) -> String {
  match align {
    Align::Left => {
      //println!("cell_width={cell_width}, strlen(s)={}", strlen(s));
      let pad = cell_width - strlen(s);
      format!("{s}{:pad$}", "")
    }
    Align::Center => {
      let pad = cell_width - strlen(s);
      let (lpad, rpad) = split_len(pad);
      format!("{:lpad$}{s}{:rpad$}", "", "")
Changes to tests/expect.rs.
1
2
3
4
5
6
7
8
9
10
11



12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28


29
30
31
32
33
34
35
36
37
38

39
40
41
42
43
44
45
46
47
48
49

50
51
52
53
54
55
56
57
58
59
60

61
62
63
64
65
66
67
68
69
70
71

72
73


74
75
76

77
78
79
80
81
82
83
1
2
3
4
5
6
7
8
9
10

11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28


29
30
31
32
33
34
35
36
37
38
39

40
41
42
43
44
45
46
47
48
49
50

51
52
53
54
55
56
57
58
59
60
61

62
63
64
65
66
67
68
69
70
71
72
73
74


75
76
77
78

79
80
81
82
83
84
85
86










-
+
+
+















-
-
+
+









-
+










-
+










-
+











+
-
-
+
+


-
+







use qpprint::tbl::{Align, Column, Data, Renderer};

// With no explicit width constraints, a long title should set the column
// witdth:
//
// A long title
// ~~~~~~~~~~~~
// short
#[test]
fn title_constrains_width() {
  let columns = [Column::new("A long title", ToString::to_string)];
  let columns = [Column::<(), ()>::new("A long title", |_cm, cd| {
    cd.to_string()
  })];
  let mut data = Data::new(columns.len());
  data.add_row(vec!["short".into()]);
  let renderer = Renderer::new(&columns, &data).header(Some('~'));
  let res = renderer.to_string();
  assert_eq!(res, "A long title\n~~~~~~~~~~~~\nshort       \n");
}

// With no explicit width constraints, a cell should set the column
// witdth:
//
// Title
// ~~~~~~~~~~~
// A long cell
#[test]
fn cell_constrains_width() {
  let columns =
    [Column::new("Title", ToString::to_string).title_align(Align::Left)];
  let columns = [Column::<(), ()>::new("Title", |_cm, cd| cd.to_string())
    .title_align(Align::Left)];
  let mut data = Data::new(columns.len());
  data.add_row(vec!["A long cell".into()]);
  let renderer = Renderer::new(&columns, &data).header(Some('~'));
  let res = renderer.to_string();
  assert_eq!(res, "Title      \n~~~~~~~~~~~\nA long cell\n");
}

#[test]
fn left_align_title() {
  let columns = [Column::new("Id", ToString::to_string)
  let columns = [Column::<(), ()>::new("Id", |_cm, cd| cd.to_string())
    .min_width(5)
    .title_align(Align::Left)];
  let data = Data::new(columns.len());
  let renderer = Renderer::new(&columns, &data).header(Some('~'));
  let res = renderer.to_string();
  assert_eq!(res, "Id   \n~~~~~\n");
}

#[test]
fn center_align_title() {
  let columns = [Column::new("I", ToString::to_string)
  let columns = [Column::<(), ()>::new("I", |_cm, cd| cd.to_string())
    .min_width(5)
    .title_align(Align::Center)];
  let data = Data::new(columns.len());
  let renderer = Renderer::new(&columns, &data).header(Some('~'));
  let res = renderer.to_string();
  assert_eq!(res, "  I  \n~~~~~\n");
}

#[test]
fn right_align_title() {
  let columns = [Column::new("I", ToString::to_string)
  let columns = [Column::<(), ()>::new("I", |_cm, cd| cd.to_string())
    .min_width(5)
    .title_align(Align::Right)];
  let data = Data::new(columns.len());
  let renderer = Renderer::new(&columns, &data).header(Some('~'));
  let res = renderer.to_string();
  assert_eq!(res, "    I\n~~~~~\n");
}


#[test]
fn trunc_cells() {
  let idcol = Column::<(), ()>::new("Id", |_cm, cd| cd.to_string())
  let idcol = Column::new("Id", ToString::to_string).cell_align(Align::Right);
  let namecol = Column::new("Name", ToString::to_string)
    .cell_align(Align::Right);
  let namecol = Column::new("Name", |_cm, cd| cd.to_string())
    .max_width(4)
    .trunc_style(2, '.');
  let namecol2 = Column::new("Title", ToString::to_string)
  let namecol2 = Column::new("Title", |_cm, cd| cd.to_string())
    .max_width(4)
    .trunc_style(1, '…');

  let columns = [idcol, namecol, namecol2];

  let mut data = Data::new(columns.len());
  data.add_row(vec![4.into(), "hell".into(), "hell".into()]);
Changes to www/changelog.md.
1
2
3
4
5
6
7
8
9
10
11



12
13
14
15
16
17
18
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21











+
+
+







# Change Log

⚠️  indicates a breaking change.

## [Unreleased]

[Details](/vdiff?from=qpprint-0.3.1&to=trunk)

### Added

### Changed

- ⚠️ Add generic types that applications can use to add metadata to columns and
  cells.

### Removed

---

## [0.3.1] - 2025-05-30