Index: .efiles ================================================================== --- .efiles +++ .efiles @@ -1,3 +1,10 @@ Cargo.toml +README.md +www/index.md +www/changelog.md src/lib.rs +src/tbl.rs +tests/expect.rs +examples/lorem.rs examples/tbl.rs +examples/newtbl.rs Index: Cargo.toml ================================================================== --- Cargo.toml +++ Cargo.toml @@ -1,20 +1,38 @@ [package] name = "qpprint" -version = "0.2.0" +version = "0.2.1" authors = ["Jan Danielsson "] edition = "2021" 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." +rust-version = "1.56" exclude = [ ".fossil-settings", ".efiles", ".fslckout", "examples", + "www", + "bacon.toml", "rustfmt.toml" ] +# https://doc.rust-lang.org/cargo/reference/manifest.html#the-badges-section +[badges] +maintenance = { status = "experimental" } + [dependencies] -terminal_size = "0.1.17" +colored = { version = "2.1.0" } +terminal_size = { version = "0.4.0" } + +[lints.clippy] +all = { level = "deny", priority = -1 } +pedantic = { level = "warn", priority = -1 } +nursery = { level = "warn", priority = -1 } +cargo = { level = "warn", priority = -1 } + +multiple_crate_versions = "allow" ADDED README.md Index: README.md ================================================================== --- /dev/null +++ README.md @@ -0,0 +1,5 @@ +# qpprint + +_qpprint_ is a collection of terminal mode pretty printing and formatting +tools. + ADDED bacon.toml Index: bacon.toml ================================================================== --- /dev/null +++ bacon.toml @@ -0,0 +1,108 @@ +# This is a configuration file for the bacon tool +# +# Bacon repository: https://github.com/Canop/bacon +# Complete help on configuration: https://dystroy.org/bacon/config/ +# You can also check bacon's own bacon.toml file +# as an example: https://github.com/Canop/bacon/blob/main/bacon.toml + +default_job = "clippy-all" + +[jobs.check] +command = ["cargo", "check", "--color", "always"] +need_stdout = false + +[jobs.check-all] +command = ["cargo", "check", "--all-targets", "--color", "always"] +need_stdout = false + +# Run clippy on the default target +[jobs.clippy] +command = [ + "cargo", "clippy", + "--color", "always", +] +need_stdout = false + +# Run clippy on all targets +# To disable some lints, you may change the job this way: +# [jobs.clippy-all] +# command = [ +# "cargo", "clippy", +# "--all-targets", +# "--color", "always", +# "--", +# "-A", "clippy::bool_to_int_with_if", +# "-A", "clippy::collapsible_if", +# "-A", "clippy::derive_partial_eq_without_eq", +# ] +# need_stdout = false +[jobs.clippy-all] +command = [ + "cargo", "clippy", + "--all-targets", + "--color", "always", +] +need_stdout = false + +# This job lets you run +# - all tests: bacon test +# - a specific test: bacon test -- config::test_default_files +# - the tests of a package: bacon test -- -- -p config +[jobs.test] +command = [ + "cargo", "test", "--color", "always", + "--", "--color", "always", # see https://github.com/Canop/bacon/issues/124 +] +need_stdout = true + +[jobs.nextest] +command = ["cargo", "nextest", "run", "--color", "always", "--hide-progress-bar", "--failure-output", "final"] +need_stdout = true +analyzer = "nextest" + +[jobs.doc] +command = ["cargo", "doc", "--color", "always", "--no-deps"] +need_stdout = false + +# If the doc compiles, then it opens in your browser and bacon switches +# to the previous job +[jobs.doc-open] +command = ["cargo", "doc", "--color", "always", "--no-deps", "--open"] +need_stdout = false +on_success = "back" # so that we don't open the browser at each change + +# You can run your application and have the result displayed in bacon, +# *if* it makes sense for this crate. +# Don't forget the `--color always` part or the errors won't be +# properly parsed. +# If your program never stops (eg a server), you may set `background` +# to false to have the cargo run output immediately displayed instead +# of waiting for program's end. If you prefer to have it restarted at +# every change, then uncomment the 'on_change_strategy' line. +[jobs.run] +command = [ + "cargo", "run", + "--color", "always", + # put launch parameters for your program behind a `--` separator +] +need_stdout = true +allow_warnings = true +background = true +#on_change_strategy = "kill_then_restart" + +# This parameterized job runs the example of your choice, as soon +# as the code compiles. +# Call it as +# bacon ex -- my-example +[jobs.ex] +command = ["cargo", "run", "--color", "always", "--example"] +need_stdout = true +allow_warnings = true + +# You may define here keybindings that would be specific to +# a project, for example a shortcut to launch a specific job. +# Shortcuts to internal functions (scrolling, toggling, etc.) +# should go in your personal global prefs.toml file instead. +[keybindings] +# alt-m = "job:my-job" +c = "job:clippy-all" # comment this to have 'c' run clippy on only the default target Index: examples/lorem.rs ================================================================== --- examples/lorem.rs +++ examples/lorem.rs @@ -19,11 +19,11 @@ parturient montes, nascetur ridiculus mus. Integer et nunc dui. \ Maecenas lobortis egestas nisi, eget facilisis mauris cursus sed. \ Mauris et nibh non est porttitor ornare ut ac tellus." ); - println!(""); + println!(); pp.print_p( &mut out, "Integer facilisis, erat id ultrices sodales, mauris justo aliquet enim, \ ut tincidunt metus est id sapien. Ut euismod pharetra tempus. Sed \ @@ -35,8 +35,7 @@ auctor bibendum risus. Aliquam erat volutpat. Mauris sed urna tempus, \ faucibus enim in, interdum justo. Quisque in nulla turpis. Integer eget \ eros vel purus vehicula maximus." ); } - // vim: set ft=rust et sw=2 ts=2 sts=2 cinoptions=2 tw=79 : ADDED examples/newtbl.rs Index: examples/newtbl.rs ================================================================== --- /dev/null +++ examples/newtbl.rs @@ -0,0 +1,105 @@ +use qpprint::tbl::{Align, CellValue, Column, Data, Renderer}; + +use colored::{Color, ColoredString, Colorize}; + +fn main() { + table1(); + println!(); + table2(); + println!(); + table3(); +} + + +fn table1() { + // + // Define table columns + // + let columns = [ + Column::new("Id", ToString::to_string), + Column::new("Name", ToString::to_string) + ]; + + // + // Create raw table data representation, with the number of columns defined + // by `columns`. + // + let mut data = Data::new(columns.len()); + + // + // Populate table cells + // + let row = vec![42.into(), "hello".into()]; + data.add_row(row); + + let row = vec![11.into(), "world".into()]; + data.add_row(row); + + // + // Configure a table renderer + // + let renderer = Renderer::new(&columns, &data).header(Some('~')); + + // + // Print the table + // + 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 = ColoredString::from(s); + if let CellValue::U64(id) = cv { + if *id > 20 { + cs.color(Color::Blue) + } else { + cs + } + } else { + cs + } + }); + let namecol = Column::new("Name", ToString::to_string).stylize(|_cv, s| { + let cs = ColoredString::from(s); + if s == "world" { + cs.color(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())]); + + let renderer = Renderer::new(&columns, &data).header(Some('-')); + + renderer.print(); +} + +fn table3() { + let idcol = Column::new("Id", ToString::to_string).cell_align(Align::Right); + let namecol = Column::new("Name", ToString::to_string) + .max_width(4) + .trunc_style(2, '.'); + let namecol2 = Column::new("Title", ToString::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(); +} + +// vim: set ft=rust et sw=2 ts=2 sts=2 cinoptions=2 tw=79 : Index: examples/tbl.rs ================================================================== --- examples/tbl.rs +++ examples/tbl.rs @@ -1,27 +1,26 @@ use qpprint::{print_table, Align, Column}; fn main() { - let mut cols = vec![]; - cols.push(Column::new("Id").align(Align::Right)); - cols.push(Column::new("Name").title_align(Align::Right)); - cols.push(Column::new("Description")); - - let mut body = vec![]; - - body.push(vec![ - "1".to_string(), - "Frank".to_string(), - "Frank Foobar".to_string(), - ]); - - body.push(vec![ - "1000".to_string(), - "Znork42".to_string(), - "An entry with a long description!".to_string(), - ]); - + let cols = vec![ + Column::new("Id").align(Align::Right), + Column::new("Name").title_align(Align::Right), + Column::new("Description"), + ]; + + let body = vec![ + vec![ + "1".to_string(), + "Frank".to_string(), + "Frank Foobar".to_string(), + ], + vec![ + "1000".to_string(), + "Znork42".to_string(), + "An entry with a long description!".to_string(), + ], + ]; print_table(&cols, &body); } // vim: set ft=rust et sw=2 ts=2 sts=2 cinoptions=2 tw=79 : Index: rustfmt.toml ================================================================== --- rustfmt.toml +++ rustfmt.toml @@ -1,8 +1,8 @@ blank_lines_upper_bound = 2 comment_width = 79 -edition = "2018" +edition = "2021" format_strings = true max_width = 79 match_block_trailing_comma = false # merge_imports = true newline_style = "Unix" Index: src/lib.rs ================================================================== --- src/lib.rs +++ src/lib.rs @@ -1,5 +1,7 @@ +pub mod tbl; + use terminal_size::{terminal_size, Height, Width}; pub enum Types { Paragraph(String) } @@ -15,27 +17,27 @@ pub title_align: Align, pub align: Align } impl Column { - pub fn new(heading: T) -> Self - where - T: ToString - { - Column { + #[allow(clippy::needless_pass_by_value)] + pub fn new(heading: impl ToString) -> Self { + Self { title: heading.to_string(), title_align: Align::Left, align: Align::Left } } - pub fn title_align(mut self, align: Align) -> Self { + #[must_use] + pub const fn title_align(mut self, align: Align) -> Self { self.title_align = align; self } - pub fn align(mut self, align: Align) -> Self { + #[must_use] + pub const fn align(mut self, align: Align) -> Self { self.align = align; self } } @@ -53,20 +55,27 @@ pub struct PPrint { indent: u16, hang: i16, maxwidth: u16 } + +impl Default for PPrint { + fn default() -> Self { + Self::new() + } +} impl PPrint { + #[must_use] pub fn new() -> Self { let size = terminal_size(); let mut maxwidth: u16 = 80; if let Some((Width(w), Height(_h))) = size { maxwidth = w; } - PPrint { + Self { indent: 0, hang: 0, maxwidth } } @@ -87,10 +96,15 @@ self.maxwidth = maxwidth; self } */ + /// # Panics + /// Writes must be successful. + #[allow(clippy::cast_possible_truncation)] + #[allow(clippy::cast_possible_wrap)] + #[allow(clippy::cast_sign_loss)] pub fn print_words(&self, out: &mut dyn std::io::Write, words: I) where I: IntoIterator, S: AsRef { @@ -97,14 +111,14 @@ let mut firstline = true; let mut newline = true; let mut space: u16 = 0; let mut col: u16 = 0; - for w in words.into_iter() { + for w in words { let w = w.as_ref(); if col + space + w.len() as u16 > self.maxwidth { - out.write(b"\n").unwrap(); + out.write_all(b"\n").unwrap(); newline = true; } if newline { let mut indent: i16 = 0; @@ -112,22 +126,26 @@ indent += self.indent as i16; if firstline { indent += self.hang; } - out.write(" ".repeat(indent as usize).as_bytes()).unwrap(); + out + .write_all(" ".repeat(indent as usize).as_bytes()) + .unwrap(); col = indent as u16; newline = false; space = 0; firstline = false; } - out.write(" ".repeat(space as usize).as_bytes()).unwrap(); + out + .write_all(" ".repeat(space as usize).as_bytes()) + .unwrap(); col += space; - out.write(w.as_bytes()).unwrap(); + out.write_all(w.as_bytes()).unwrap(); col += w.len() as u16; let ch = w.chars().last().unwrap(); match ch { '.' | '?' | '!' => { @@ -136,11 +154,11 @@ _ => { space = 1; } } } - out.write(b"\n").unwrap(); + out.write_all(b"\n").unwrap(); } pub fn print_p(&self, out: &mut dyn std::io::Write, para: &str) { let words = wordify(para); self.print_words(out, &words); @@ -156,10 +174,11 @@ } } } +#[allow(clippy::similar_names)] pub fn print_table(cols: &[Column], body: &Vec>) { // Used to keep track of the maximum column width. let mut colw: Vec = cols.iter().map(|col| col.title.len()).collect(); // Iterate over body cells @@ -193,11 +212,11 @@ println!("{}", fields.join(" ")); // print heading underline fields.clear(); for w in &colw { - fields.push(format!("{}", "~".repeat(*w))); + fields.push("~".repeat(*w).to_string()); } println!("{}", fields.join(" ")); for row in body { fields.clear(); ADDED src/tbl.rs Index: src/tbl.rs ================================================================== --- /dev/null +++ src/tbl.rs @@ -0,0 +1,436 @@ +use std::{ + borrow::Cow, + fmt, + iter::{self, zip} +}; + +pub use colored::{Color, ColoredString, Colorize}; + +pub use super::Align; + + +#[allow(clippy::type_complexity)] +pub struct Column { + title: String, + min_width: Option, + max_width: Option, + trunc_len: usize, + trunc_ch: char, + renderer: Box String>, + stylize: Option ColoredString>>, + title_align: Align, + cell_align: Align +} + +impl Column { + #[allow(clippy::needless_pass_by_value)] + pub fn new( + heading: impl ToString, + renderer: impl Fn(&CellValue) -> 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 + } + } + + #[must_use] + pub fn min_width(mut self, min: usize) -> Self { + self.min_width_ref(min); + self + } + + /// # Panics + /// Panics if a maximum width has been configured for the `Column` and `min` + /// is less than the maximum width. + pub fn min_width_ref(&mut self, min: usize) -> &mut Self { + // Make sure that min width is not greater than max width + if let Some(max) = self.max_width { + assert!(min <= max); + } + self.min_width = Some(min); + self + } + + #[must_use] + pub fn max_width(mut self, max: usize) -> Self { + self.max_width_ref(max); + self + } + + /// # Panics + /// `max` must not be less than a previously configured `min` width. + pub fn max_width_ref(&mut self, max: usize) -> &mut Self { + // Make sure that max width is not less than min width + if let Some(min) = self.min_width { + assert!(max >= min); + } + self.max_width = Some(max); + self + } + + #[must_use] + pub fn trunc_style(mut self, len: usize, ch: char) -> Self { + self.trunc_style_ref(len, ch); + self + } + + pub fn trunc_style_ref(&mut self, len: usize, ch: char) -> &mut Self { + self.trunc_len = len; + self.trunc_ch = ch; + self + } + + #[must_use] + pub fn stylize( + mut self, + f: impl Fn(&CellValue, &str) -> ColoredString + 'static + ) -> Self { + self.stylize = Some(Box::new(f)); + self + } + + #[must_use] + pub fn title_align(mut self, align: Align) -> Self { + self.title_align_ref(align); + self + } + + pub fn title_align_ref(&mut self, align: Align) -> &mut Self { + self.title_align = align; + self + } + + #[must_use] + pub fn cell_align(mut self, align: Align) -> Self { + self.cell_align_ref(align); + self + } + + pub fn cell_align_ref(&mut self, align: Align) -> &mut Self { + self.cell_align = align; + self + } +} + + +pub enum CellValue { + Str(String), + U64(u64) +} + +impl From for CellValue { + fn from(val: String) -> Self { + Self::Str(val) + } +} + +impl From<&str> for CellValue { + fn from(val: &str) -> Self { + Self::Str(val.to_string()) + } +} + +impl From for CellValue { + fn from(val: u64) -> Self { + Self::U64(val) + } +} + +impl fmt::Display for CellValue { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + match self { + Self::Str(ref s) => write!(f, "{s}"), + Self::U64(v) => write!(f, "{v}") + } + } +} + + +pub struct Data { + num_cols: usize, + cells: Vec> +} + +impl Data { + #[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) { + assert_eq!(row.len(), self.num_cols); + self.cells.push(row); + } +} + +pub trait CellRender { + fn stringify(); + + fn print(); +} + + +pub struct Renderer<'a> { + show_header: bool, + header_underline: Option, + col_spacing: usize, + cols: &'a [Column], + data: &'a [Vec] +} + +impl<'a> Renderer<'a> { + #[must_use] + pub fn new(cols: &'a [Column], data: &'a Data) -> Self { + Self { + show_header: false, + header_underline: None, + col_spacing: 2, + cols, + data: &data.cells + } + } + + #[must_use] + pub fn header(mut self, underline: Option) -> Self { + self.header_ref(underline); + self + } + + pub fn header_ref(&mut self, underline: Option) -> &mut Self { + self.show_header = true; + self.header_underline = underline; + self + } + + #[must_use] + pub fn column_spacing(mut self, n: usize) -> Self { + self.column_spacing_ref(n); + self + } + + pub fn column_spacing_ref(&mut self, n: usize) -> &mut Self { + self.col_spacing = n; + self + } + + pub fn print(&self) { + println!("{}", self); + } +} + + +impl fmt::Display for Renderer<'_> { + 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::with_capacity(self.data.len()); + + // + // Used to keep track of auto-detected column widths. + // + // If the renderer is configured to show a header, then initialize to the + // column titles. Otherwise initialize to 0. + // + // If a column min and/or max widths have been configured, the column + // widths will be clamped later. + // + let mut col_widths: Vec = if self.show_header { + self.cols.iter().map(|c| c.title.len()).collect() + } else { + vec![0; self.cols.len()] + }; + + // + // Convert table of CellValues into a table of Strings. + // + // In the process, keep increasing the column widths as needed + // + for row in self.data { + let mut rrow = Vec::with_capacity(self.cols.len()); + /* + for (icol, (col, cell)) in zip(self.cols, row).enumerate() { + let cell_str = (col.renderer)(cell); + + // ToDo: Cut down to size if cell_str.len() exceeds column's max_width + + //if cell_str.len() > col_width {} + + col_widths[icol] = std::cmp::max(col_widths[icol], cell_str.len()); + 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); + + // 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, cell_str.len()); + rrow.push(cell_str); + } + + rendered.push(rrow); + } + + // + // If columns have a minimum and/or maximum width configured, then apply + // these limits to col_widths + // + let col_widths: Vec = zip(col_widths, self.cols) + .map(|(cw, col)| clamp_width(cw, col.min_width, col.max_width)) + .collect(); + + let colspace = " ".repeat(self.col_spacing); + + let mut fields = Vec::with_capacity(self.cols.len()); + + if self.show_header { + // + // Print column headers + // + for (col, cw) in std::iter::zip(self.cols, &col_widths) { + let title = trunc_str(&col.title, *cw, col.trunc_len, col.trunc_ch); + + match col.title_align { + Align::Left => { + fields.push(format!("{title: { + fields.push(format!("{title:^cw$}")); + } + Align::Right => { + fields.push(format!("{title:>cw$}")); + } + } + } + writeln!(f, "{}", fields.join(&colspace))?; + + + // + // Print heading underline + // + if let Some(ch) = self.header_underline { + fields.clear(); + for cw in &col_widths { + let line = iter::repeat(ch).take(*cw).collect::(); + fields.push(line); + } + writeln!(f, "{}", fields.join(&colspace))?; + } + } + + + // + // At this point `rendered` is a table of String's + // + for (cvs, row) in iter::zip(self.data, rendered) { + fields.clear(); + + 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); + match col.cell_align { + Align::Left => { + fields.push(format!("{cell: { + fields.push(format!("{cell:^cw$}")); + } + Align::Right => { + fields.push(format!("{cell:>cw$}")); + } + } + } else { + match col.cell_align { + Align::Left => { + fields.push(format!("{cell: { + fields.push(format!("{cell:^cw$}")); + } + Align::Right => { + fields.push(format!("{cell:>cw$}")); + } + } + } + } + + writeln!(f, "{}", fields.join(&colspace))?; + } + Ok(()) + } +} + + +fn clamp_width(w: usize, min: Option, max: Option) -> usize { + let w = min.map_or(w, |min| if w < min { min } else { w }); + max.map_or(w, |max| if w > max { max } else { w }) +} + +fn trunc_str( + s: &str, + width: usize, + trunc_len: usize, + trunc_ch: char +) -> Cow { + if s.len() > width { + let trunc = s[..s.len() - trunc_len - 1].to_string(); + let cont = iter::repeat(trunc_ch).take(trunc_len).collect::(); + let s = format!("{trunc}{cont}"); + Cow::from(s) + } else { + Cow::from(s) + } +} + + +#[cfg(test)] +mod tests { + use super::{clamp_width, trunc_str}; + + #[test] + fn truncing() { + assert_eq!(trunc_str("hello", 4, 1, '.').into_owned(), "hel."); + assert_eq!(trunc_str("hello", 4, 2, '.').into_owned(), "he.."); + } + + #[test] + fn clamping() { + assert_eq!(clamp_width(0, None, None), 0); + + assert_eq!(clamp_width(0, Some(2), None), 2); + assert_eq!(clamp_width(8, Some(2), None), 8); + + assert_eq!(clamp_width(0, None, Some(8)), 0); + assert_eq!(clamp_width(10, None, Some(8)), 8); + + assert_eq!(clamp_width(0, Some(2), Some(8)), 2); + assert_eq!(clamp_width(4, Some(2), Some(8)), 4); + assert_eq!(clamp_width(10, Some(2), Some(8)), 8); + } +} + +// vim: set ft=rust et sw=2 ts=2 sts=2 cinoptions=2 tw=79 : ADDED tests/expect.rs Index: tests/expect.rs ================================================================== --- /dev/null +++ tests/expect.rs @@ -0,0 +1,103 @@ +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 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 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) + .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) + .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) + .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", ToString::to_string).cell_align(Align::Right); + let namecol = Column::new("Name", ToString::to_string) + .max_width(4) + .trunc_style(2, '.'); + let namecol2 = Column::new("Title", ToString::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('=')) + .column_spacing(1); + + let res = renderer.to_string(); + assert_eq!( + res, + r"Id Name Tit… +== ==== ==== + 4 hell hell +42 he.. hel… +11 wo.. wor… +" + ); +} + +// vim: set ft=rust et sw=2 ts=2 sts=2 cinoptions=2 tw=79 : ADDED www/changelog.md Index: www/changelog.md ================================================================== --- /dev/null +++ www/changelog.md @@ -0,0 +1,18 @@ +# Change Log + +⚠️ indicates a breaking change. + +## [Unreleased] + +[Details](/vdiff?from=qpprint-0.2.0&to=trunk) + +### Added + +- Add a new table implementation under `tbl::*`. + +### Changed + +### Removed + +--- + ADDED www/index.md Index: www/index.md ================================================================== --- /dev/null +++ www/index.md @@ -0,0 +1,17 @@ +# qpprint + +The _qpprint_ crate is a collection of terminal mode pretty printing and +formatting tools. + + +## Change log + +The details of changes can always be found in the timeline, but for a +high-level view of changes between released versions there's a manually +maintained [Change Log](./changelog.md). + + +## Project Status + +_qpprint_ is in early development stages. +