diff --git a/.gitignore b/.gitignore index 92defc3f3f..6373c46a33 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ history.txt -/target \ No newline at end of file +/target +/.vscode \ No newline at end of file diff --git a/Cargo.lock b/Cargo.lock index 5ff65ec7db..433c8b4a2d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -294,6 +294,7 @@ dependencies = [ "nu-path", "nu-protocol", "nu-table", + "nu-term-grid", "pretty_assertions", "reedline", "tempfile", @@ -523,7 +524,9 @@ dependencies = [ "nu-path", "nu-protocol", "nu-table", + "nu-term-grid", "sysinfo", + "terminal_size", "thiserror", ] @@ -587,6 +590,13 @@ dependencies = [ "unicode-width", ] +[[package]] +name = "nu-term-grid" +version = "0.36.0" +dependencies = [ + "unicode-width", +] + [[package]] name = "num-integer" version = "0.1.44" @@ -1047,6 +1057,16 @@ dependencies = [ "winapi", ] +[[package]] +name = "terminal_size" +version = "0.1.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "633c1a546cee861a1a6d0dc69ebeca693bf4296661ba7852b9d21d159e0506df" +dependencies = [ + "libc", + "winapi", +] + [[package]] name = "textwrap" version = "0.14.2" diff --git a/Cargo.toml b/Cargo.toml index 01c94b401a..3d819c0621 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -19,6 +19,7 @@ nu-parser = { path="./crates/nu-parser" } nu-path = { path="./crates/nu-path" } nu-protocol = { path = "./crates/nu-protocol" } nu-table = { path = "./crates/nu-table" } +nu-term-grid = { path = "./crates/nu-term-grid" } miette = "3.0.0" # mimalloc = { version = "*", default-features = false } diff --git a/crates/nu-command/Cargo.toml b/crates/nu-command/Cargo.toml index d840fb7d33..4d0f13c78b 100644 --- a/crates/nu-command/Cargo.toml +++ b/crates/nu-command/Cargo.toml @@ -11,9 +11,11 @@ nu-json = { path = "../nu-json" } nu-path = { path = "../nu-path" } nu-protocol = { path = "../nu-protocol" } nu-table = { path = "../nu-table" } +nu-term-grid = { path = "../nu-term-grid" } # Potential dependencies for extras glob = "0.3.0" thiserror = "1.0.29" sysinfo = "0.20.4" chrono = { version="0.4.19", features=["serde"] } +terminal_size = "0.1.17" diff --git a/crates/nu-command/src/default_context.rs b/crates/nu-command/src/default_context.rs index b7e5157dcc..e35574a7ab 100644 --- a/crates/nu-command/src/default_context.rs +++ b/crates/nu-command/src/default_context.rs @@ -27,6 +27,7 @@ pub fn create_default_context() -> Rc> { working_set.add_decl(Box::new(From)); working_set.add_decl(Box::new(FromJson)); working_set.add_decl(Box::new(Get)); + working_set.add_decl(Box::new(Griddle)); working_set.add_decl(Box::new(Help)); working_set.add_decl(Box::new(Hide)); working_set.add_decl(Box::new(If)); diff --git a/crates/nu-command/src/viewers/griddle.rs b/crates/nu-command/src/viewers/griddle.rs new file mode 100644 index 0000000000..f16ec4a743 --- /dev/null +++ b/crates/nu-command/src/viewers/griddle.rs @@ -0,0 +1,289 @@ +use nu_engine::CallExt; +use nu_protocol::{ + ast::{Call, PathMember}, + engine::{Command, EvaluationContext}, + Signature, Span, SyntaxShape, Value, +}; +use nu_term_grid::grid::{Alignment, Cell, Direction, Filling, Grid, GridOptions}; +use terminal_size::{Height, Width}; + +pub struct Griddle; + +impl Command for Griddle { + fn name(&self) -> &str { + "grid" + } + + fn usage(&self) -> &str { + "Renders the output to a textual terminal grid." + } + + fn signature(&self) -> nu_protocol::Signature { + Signature::build("grid").named( + "columns", + SyntaxShape::Int, + "number of columns wide", + Some('c'), + ) + } + + fn extra_usage(&self) -> &str { + r#"grid was built to give a concise gridded layout for ls. however, +it determines what to put in the grid by looking for a column named +'name'. this works great for tables and records but for lists we +need to do something different. such as with '[one two three] | grid' +it creates a fake column called 'name' for these values so that it +prints out the list properly."# + } + + fn run( + &self, + context: &EvaluationContext, + call: &Call, + input: Value, + ) -> Result { + let columns_param: Option = call.get_flag(context, "columns")?; + + match input { + Value::List { vals, .. } => { + // dbg!("value::list"); + let data = convert_to_list2(vals); + if let Some(items) = data { + Ok(create_grid_output2(items, call, columns_param)) + } else { + Ok(Value::Nothing { span: call.head }) + } + } + Value::Stream { stream, .. } => { + // dbg!("value::stream"); + let data = convert_to_list2(stream); + if let Some(items) = data { + Ok(create_grid_output2(items, call, columns_param)) + } else { + // dbg!(data); + Ok(Value::Nothing { span: call.head }) + } + } + Value::Record { cols, vals, .. } => { + // dbg!("value::record"); + let mut items = vec![]; + + for (i, (c, v)) in cols.into_iter().zip(vals.into_iter()).enumerate() { + items.push((i, c, v.into_string())) + } + + Ok(create_grid_output2(items, call, columns_param)) + } + x => { + // dbg!("other value"); + // dbg!(x.get_type()); + Ok(x) + } + } + } +} + +fn create_grid_output2( + items: Vec<(usize, String, String)>, + call: &Call, + columns_param: Option, +) -> Value { + let mut grid = Grid::new(GridOptions { + direction: Direction::TopToBottom, + filling: Filling::Text(" | ".into()), + }); + + for (_row_index, header, value) in items { + // only output value if the header name is 'name' + if header == "name" { + let mut cell = Cell::from(value); + cell.alignment = Alignment::Right; + grid.add(cell); + } + } + + let cols = if let Some(col) = columns_param { + col.parse::().unwrap_or(80) + } else if let Some((Width(w), Height(_h))) = terminal_size::terminal_size() { + w + } else { + 80u16 + }; + + if let Some(grid_display) = grid.fit_into_width(cols as usize) { + Value::String { + val: grid_display.to_string(), + span: call.head, + } + } else { + Value::String { + val: format!("Couldn't fit grid into {} columns!", cols), + span: call.head, + } + } +} + +// fn create_grid_output( +// items: Vec>, +// call: &Call, +// columns_param: Option, +// ) -> Value { +// let mut grid = Grid::new(GridOptions { +// direction: Direction::TopToBottom, +// filling: Filling::Text(" | ".into()), +// }); + +// for list in items { +// dbg!(&list); +// // looks like '&list = [ "0", "one",]' +// let a_string = (&list[1]).to_string(); +// let mut cell = Cell::from(a_string); +// cell.alignment = Alignment::Right; +// grid.add(cell); +// } + +// let cols = if let Some(col) = columns_param { +// col.parse::().unwrap_or(80) +// } else { +// // 80usize +// if let Some((Width(w), Height(_h))) = terminal_size::terminal_size() { +// w +// } else { +// 80u16 +// } +// }; + +// // eprintln!("columns size = {}", cols); +// if let Some(grid_display) = grid.fit_into_width(cols as usize) { +// // println!("{}", grid_display); +// Value::String { +// val: grid_display.to_string(), +// span: call.head, +// } +// } else { +// // println!("Couldn't fit grid into 80 columns!"); +// Value::String { +// val: format!("Couldn't fit grid into {} columns!", cols), +// span: call.head, +// } +// } +// } + +fn convert_to_list2(iter: impl IntoIterator) -> Option> { + let mut iter = iter.into_iter().peekable(); + + if let Some(first) = iter.peek() { + let mut headers = first.columns(); + + if !headers.is_empty() { + headers.insert(0, "#".into()); + } + + let mut data = vec![]; + + for (row_num, item) in iter.enumerate() { + let mut row = vec![row_num.to_string()]; + + if headers.is_empty() { + row.push(item.into_string()) + } else { + for header in headers.iter().skip(1) { + let result = match item { + Value::Record { .. } => { + item.clone().follow_cell_path(&[PathMember::String { + val: header.into(), + span: Span::unknown(), + }]) + } + _ => Ok(item.clone()), + }; + + match result { + Ok(value) => row.push(value.into_string()), + Err(_) => row.push(String::new()), + } + } + } + + data.push(row); + } + + // TODO: later, let's color these string with LS_COLORS + // let h: Vec = headers.into_iter().map(|x| x.trim().to_string()).collect(); + // let d: Vec> = data.into_iter().map(|x| x.into_iter().collect()).collect(); + + let mut h: Vec = headers.into_iter().collect(); + // let d: Vec> = data.into_iter().collect(); + + // This is just a list + if h.is_empty() { + // let's fake the header + h.push("#".to_string()); + h.push("name".to_string()); + } + + // this tuple is (row_index, header_name, value) + let mut interleaved = vec![]; + for (i, v) in data.into_iter().enumerate() { + for (n, s) in v.into_iter().enumerate() { + if h.len() == 1 { + // always get the 1th element since this is a simple list + // and we hacked the header above because it was empty + // 0th element is an index, 1th element is the value + interleaved.push((i, h[1].clone(), s)) + } else { + interleaved.push((i, h[n].clone(), s)) + } + } + } + + Some(interleaved) + } else { + None + } +} + +// fn convert_to_list(iter: impl IntoIterator) -> Option>> { +// let mut iter = iter.into_iter().peekable(); +// let mut data = vec![]; + +// if let Some(first) = iter.peek() { +// // dbg!(&first); +// let mut headers = first.columns(); + +// if !headers.is_empty() { +// headers.insert(0, "#".into()); +// } + +// for (row_num, item) in iter.enumerate() { +// let mut row = vec![row_num.to_string()]; + +// if headers.is_empty() { +// row.push(item.into_string()) +// } else { +// for header in headers.iter().skip(1) { +// let result = match item { +// Value::Record { .. } => { +// item.clone().follow_cell_path(&[PathMember::String { +// val: header.into(), +// span: Span::unknown(), +// }]) +// } +// _ => Ok(item.clone()), +// }; + +// match result { +// Ok(value) => row.push(value.into_string()), +// Err(_) => row.push(String::new()), +// } +// } +// } + +// data.push(row); +// } + +// Some(data) +// } else { +// None +// } +// } diff --git a/crates/nu-command/src/viewers/mod.rs b/crates/nu-command/src/viewers/mod.rs index 0ed6450087..3ff09eff39 100644 --- a/crates/nu-command/src/viewers/mod.rs +++ b/crates/nu-command/src/viewers/mod.rs @@ -1,3 +1,5 @@ +mod griddle; mod table; +pub use griddle::Griddle; pub use table::Table; diff --git a/crates/nu-command/src/viewers/table.rs b/crates/nu-command/src/viewers/table.rs index e3b64e34ee..b1d31ba299 100644 --- a/crates/nu-command/src/viewers/table.rs +++ b/crates/nu-command/src/viewers/table.rs @@ -1,9 +1,9 @@ -use std::collections::HashMap; - use nu_protocol::ast::{Call, PathMember}; use nu_protocol::engine::{Command, EvaluationContext}; use nu_protocol::{Signature, Span, Value}; use nu_table::StyledString; +use std::collections::HashMap; +use terminal_size::{Height, Width}; pub struct Table; @@ -27,12 +27,18 @@ impl Command for Table { call: &Call, input: Value, ) -> Result { + let term_width = if let Some((Width(w), Height(_h))) = terminal_size::terminal_size() { + w as usize + } else { + 80usize + }; + match input { Value::List { vals, .. } => { let table = convert_to_table(vals); if let Some(table) = table { - let result = nu_table::draw_table(&table, 80, &HashMap::new()); + let result = nu_table::draw_table(&table, term_width, &HashMap::new()); Ok(Value::String { val: result, @@ -46,7 +52,7 @@ impl Command for Table { let table = convert_to_table(stream); if let Some(table) = table { - let result = nu_table::draw_table(&table, 80, &HashMap::new()); + let result = nu_table::draw_table(&table, term_width, &HashMap::new()); Ok(Value::String { val: result, @@ -78,7 +84,7 @@ impl Command for Table { theme: nu_table::Theme::rounded(), }; - let result = nu_table::draw_table(&table, 80, &HashMap::new()); + let result = nu_table::draw_table(&table, term_width, &HashMap::new()); Ok(Value::String { val: result, diff --git a/crates/nu-term-grid/.gitignore b/crates/nu-term-grid/.gitignore new file mode 100644 index 0000000000..4c234e523b --- /dev/null +++ b/crates/nu-term-grid/.gitignore @@ -0,0 +1,22 @@ +/target +/scratch +**/*.rs.bk +history.txt +tests/fixtures/nuplayground +crates/*/target + +# Debian/Ubuntu +debian/.debhelper/ +debian/debhelper-build-stamp +debian/files +debian/nu.substvars +debian/nu/ + +# macOS junk +.DS_Store + +# JetBrains' IDE items +.idea/* + +# VSCode's IDE items +.vscode/* diff --git a/crates/nu-term-grid/Cargo.toml b/crates/nu-term-grid/Cargo.toml new file mode 100644 index 0000000000..183de8ecc9 --- /dev/null +++ b/crates/nu-term-grid/Cargo.toml @@ -0,0 +1,15 @@ +[package] +authors = ["The Nu Project Contributors"] +description = "Nushell grid printing" +edition = "2018" +license = "MIT" +name = "nu-term-grid" +version = "0.36.0" + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html +[[bin]] +name = "grid" +path = "src/main.rs" + +[dependencies] +unicode-width = "0.1.9" diff --git a/crates/nu-term-grid/src/grid.rs b/crates/nu-term-grid/src/grid.rs new file mode 100644 index 0000000000..212005354c --- /dev/null +++ b/crates/nu-term-grid/src/grid.rs @@ -0,0 +1,758 @@ +// Thanks to https://github.com/ogham/rust-term-grid for making this available + +//! This library arranges textual data in a grid format suitable for +//! fixed-width fonts, using an algorithm to minimise the amount of space +//! needed. For example: +//! +//! ```rust +//! use nu_term_grid::grid::{Grid, GridOptions, Direction, Filling, Cell}; +//! +//! let mut grid = Grid::new(GridOptions { +//! filling: Filling::Spaces(1), +//! direction: Direction::LeftToRight, +//! }); +//! +//! for s in &["one", "two", "three", "four", "five", "six", "seven", +//! "eight", "nine", "ten", "eleven", "twelve"] +//! { +//! grid.add(Cell::from(*s)); +//! } +//! +//! println!("{}", grid.fit_into_width(24).unwrap()); +//! ``` +//! +//! Produces the following tabular result: +//! +//! ```text +//! one two three four +//! five six seven eight +//! nine ten eleven twelve +//! ``` +//! +//! +//! ## Creating a grid +//! +//! To add data to a grid, first create a new [`Grid`] value, and then add +//! cells to them with the `add` function. +//! +//! There are two options that must be specified in the [`GridOptions`] value +//! that dictate how the grid is formatted: +//! +//! - `filling`: what to put in between two columns — either a number of +//! spaces, or a text string; +//! - `direction`, which specifies whether the cells should go along +//! rows, or columns: +//! - `Direction::LeftToRight` starts them in the top left and +//! moves *rightwards*, going to the start of a new row after reaching the +//! final column; +//! - `Direction::TopToBottom` starts them in the top left and moves +//! *downwards*, going to the top of a new column after reaching the final +//! row. +//! +//! +//! ## Displaying a grid +//! +//! When display a grid, you can either specify the number of columns in advance, +//! or try to find the maximum number of columns that can fit in an area of a +//! given width. +//! +//! Splitting a series of cells into columns — or, in other words, starting a new +//! row every n cells — is achieved with the [`fit_into_columns`] function +//! on a `Grid` value. It takes as its argument the number of columns. +//! +//! Trying to fit as much data onto one screen as possible is the main use case +//! for specifying a maximum width instead. This is achieved with the +//! [`fit_into_width`] function. It takes the maximum allowed width, including +//! separators, as its argument. However, it returns an *optional* [`Display`] +//! value, depending on whether any of the cells actually had a width greater than +//! the maximum width! If this is the case, your best bet is to just output the +//! cells with one per line. +//! +//! +//! ## Cells and data +//! +//! Grids to not take `String`s or `&str`s — they take [`Cell`] values. +//! +//! A **Cell** is a struct containing an individual cell’s contents, as a string, +//! and its pre-computed length, which gets used when calculating a grid’s final +//! dimensions. Usually, you want the *Unicode width* of the string to be used for +//! this, so you can turn a `String` into a `Cell` with the `.into()` function. +//! +//! However, you may also want to supply your own width: when you already know the +//! width in advance, or when you want to change the measurement, such as skipping +//! over terminal control characters. For cases like these, the fields on the +//! `Cell` values are public, meaning you can construct your own instances as +//! necessary. +//! +//! [`Cell`]: ./struct.Cell.html +//! [`Display`]: ./struct.Display.html +//! [`Grid`]: ./struct.Grid.html +//! [`fit_into_columns`]: ./struct.Grid.html#method.fit_into_columns +//! [`fit_into_width`]: ./struct.Grid.html#method.fit_into_width +//! [`GridOptions`]: ./struct.GridOptions.html + +use std::cmp::max; +use std::fmt; +use std::iter::repeat; + +// extern crate unicode_width; +use unicode_width::UnicodeWidthStr; + +/// Alignment indicate on which side the content should stick if some filling +/// is required. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Alignment { + /// The content will stick to the left. + Left, + + /// The content will stick to the right. + Right, +} + +/// A **Cell** is the combination of a string and its pre-computed length. +/// +/// The easiest way to create a Cell is just by using `string.into()`, which +/// uses the **unicode width** of the string (see the `unicode_width` crate). +/// However, the fields are public, if you wish to provide your own length. +#[derive(PartialEq, Debug, Clone)] +pub struct Cell { + /// The string to display when this cell gets rendered. + pub contents: String, + + /// The pre-computed length of the string. + pub width: Width, + + /// The side (left/right) to align the content if some filling is required. + pub alignment: Alignment, +} + +impl From for Cell { + fn from(string: String) -> Self { + Self { + width: UnicodeWidthStr::width(&*string), + contents: string, + alignment: Alignment::Left, + } + } +} + +impl<'a> From<&'a str> for Cell { + fn from(string: &'a str) -> Self { + Self { + width: UnicodeWidthStr::width(&*string), + contents: string.into(), + alignment: Alignment::Left, + } + } +} + +/// Direction cells should be written in — either across, or downwards. +#[derive(PartialEq, Debug, Copy, Clone)] +pub enum Direction { + /// Starts at the top left and moves rightwards, going back to the first + /// column for a new row, like a typewriter. + LeftToRight, + + /// Starts at the top left and moves downwards, going back to the first + /// row for a new column, like how `ls` lists files by default. + TopToBottom, +} + +/// The width of a cell, in columns. +pub type Width = usize; + +/// The text to put in between each pair of columns. +/// This does not include any spaces used when aligning cells. +#[derive(PartialEq, Debug)] +pub enum Filling { + /// A certain number of spaces should be used as the separator. + Spaces(Width), + + /// An arbitrary string. + /// `"|"` is a common choice. + Text(String), +} + +impl Filling { + fn width(&self) -> Width { + match *self { + Filling::Spaces(w) => w, + Filling::Text(ref t) => UnicodeWidthStr::width(&t[..]), + } + } +} + +/// The user-assignable options for a grid view that should be passed to +/// [`Grid::new()`](struct.Grid.html#method.new). +#[derive(PartialEq, Debug)] +pub struct GridOptions { + /// The direction that the cells should be written in — either + /// across, or downwards. + pub direction: Direction, + + /// The number of spaces to put in between each column of cells. + pub filling: Filling, +} + +#[derive(PartialEq, Debug)] +struct Dimensions { + /// The number of lines in the grid. + num_lines: Width, + + /// The width of each column in the grid. The length of this vector serves + /// as the number of columns. + widths: Vec, +} + +impl Dimensions { + fn total_width(&self, separator_width: Width) -> Width { + if self.widths.is_empty() { + 0 + } else { + let values = self.widths.iter().sum::(); + let separators = separator_width * (self.widths.len() - 1); + values + separators + } + } +} + +/// Everything needed to format the cells with the grid options. +/// +/// For more information, see the [`grid` crate documentation](index.html). +#[derive(PartialEq, Debug)] +pub struct Grid { + options: GridOptions, + cells: Vec, + widest_cell_length: Width, + width_sum: Width, + cell_count: usize, +} + +impl Grid { + /// Creates a new grid view with the given options. + pub fn new(options: GridOptions) -> Self { + let cells = Vec::new(); + Self { + options, + cells, + widest_cell_length: 0, + width_sum: 0, + cell_count: 0, + } + } + + /// Reserves space in the vector for the given number of additional cells + /// to be added. (See the `Vec::reserve` function.) + pub fn reserve(&mut self, additional: usize) { + self.cells.reserve(additional) + } + + /// Adds another cell onto the vector. + pub fn add(&mut self, cell: Cell) { + if cell.width > self.widest_cell_length { + self.widest_cell_length = cell.width; + } + self.width_sum += cell.width; + self.cell_count += 1; + self.cells.push(cell) + } + + /// Returns a displayable grid that’s been packed to fit into the given + /// width in the fewest number of rows. + /// + /// Returns `None` if any of the cells has a width greater than the + /// maximum width. + pub fn fit_into_width(&self, maximum_width: Width) -> Option> { + self.width_dimensions(maximum_width).map(|dims| Display { + grid: self, + dimensions: dims, + }) + } + + /// Returns a displayable grid with the given number of columns, and no + /// maximum width. + pub fn fit_into_columns(&self, num_columns: usize) -> Display<'_> { + Display { + grid: self, + dimensions: self.columns_dimensions(num_columns), + } + } + + fn columns_dimensions(&self, num_columns: usize) -> Dimensions { + let mut num_lines = self.cells.len() / num_columns; + if self.cells.len() % num_columns != 0 { + num_lines += 1; + } + + self.column_widths(num_lines, num_columns) + } + + fn column_widths(&self, num_lines: usize, num_columns: usize) -> Dimensions { + let mut widths: Vec = repeat(0).take(num_columns).collect(); + for (index, cell) in self.cells.iter().enumerate() { + let index = match self.options.direction { + Direction::LeftToRight => index % num_columns, + Direction::TopToBottom => index / num_lines, + }; + widths[index] = max(widths[index], cell.width); + } + + Dimensions { num_lines, widths } + } + + fn theoretical_max_num_lines(&self, maximum_width: usize) -> usize { + // TODO: Make code readable / efficient. + let mut theoretical_min_num_cols = 0; + let mut col_total_width_so_far = 0; + + let mut cells = self.cells.clone(); + cells.sort_unstable_by(|a, b| b.width.cmp(&a.width)); // Sort in reverse order + + for cell in &cells { + if cell.width + col_total_width_so_far <= maximum_width { + theoretical_min_num_cols += 1; + col_total_width_so_far += cell.width; + } else { + let mut theoretical_max_num_lines = self.cell_count / theoretical_min_num_cols; + if self.cell_count % theoretical_min_num_cols != 0 { + theoretical_max_num_lines += 1; + } + return theoretical_max_num_lines; + } + col_total_width_so_far += self.options.filling.width() + } + + // If we make it to this point, we have exhausted all cells before + // reaching the maximum width; the theoretical max number of lines + // needed to display all cells is 1. + 1 + } + + fn width_dimensions(&self, maximum_width: Width) -> Option { + if self.widest_cell_length > maximum_width { + // Largest cell is wider than maximum width; it is impossible to fit. + return None; + } + + if self.cell_count == 0 { + return Some(Dimensions { + num_lines: 0, + widths: Vec::new(), + }); + } + + if self.cell_count == 1 { + let the_cell = &self.cells[0]; + return Some(Dimensions { + num_lines: 1, + widths: vec![the_cell.width], + }); + } + + let theoretical_max_num_lines = self.theoretical_max_num_lines(maximum_width); + if theoretical_max_num_lines == 1 { + // This if—statement is neccesary for the function to work correctly + // for small inputs. + return Some(Dimensions { + num_lines: 1, + // I clone self.cells twice. Once here, and once in + // self.theoretical_max_num_lines. Perhaps not the best for + // performance? + widths: self + .cells + .clone() + .into_iter() + .map(|cell| cell.width) + .collect(), + }); + } + // Instead of numbers of columns, try to find the fewest number of *lines* + // that the output will fit in. + let mut smallest_dimensions_yet = None; + for num_lines in (1..=theoretical_max_num_lines).rev() { + // The number of columns is the number of cells divided by the number + // of lines, *rounded up*. + let mut num_columns = self.cell_count / num_lines; + if self.cell_count % num_lines != 0 { + num_columns += 1; + } + // Early abort: if there are so many columns that the width of the + // *column separators* is bigger than the width of the screen, then + // don’t even try to tabulate it. + // This is actually a necessary check, because the width is stored as + // a usize, and making it go negative makes it huge instead, but it + // also serves as a speed-up. + let total_separator_width = (num_columns - 1) * self.options.filling.width(); + if maximum_width < total_separator_width { + continue; + } + + // Remove the separator width from the available space. + let adjusted_width = maximum_width - total_separator_width; + let potential_dimensions = self.column_widths(num_lines, num_columns); + if potential_dimensions.widths.iter().sum::() < adjusted_width { + smallest_dimensions_yet = Some(potential_dimensions); + } else { + return smallest_dimensions_yet; + } + } + + None + } +} + +/// A displayable representation of a [`Grid`](struct.Grid.html). +/// +/// This type implements `Display`, so you can get the textual version +/// of the grid by calling `.to_string()`. +#[derive(PartialEq, Debug)] +pub struct Display<'grid> { + /// The grid to display. + grid: &'grid Grid, + + /// The pre-computed column widths for this grid. + dimensions: Dimensions, +} + +impl Display<'_> { + /// Returns how many columns this display takes up, based on the separator + /// width and the number and width of the columns. + pub fn width(&self) -> Width { + self.dimensions + .total_width(self.grid.options.filling.width()) + } + + /// Returns how many rows this display takes up. + pub fn row_count(&self) -> usize { + self.dimensions.num_lines + } + + /// Returns whether this display takes up as many columns as were allotted + /// to it. + /// + /// It’s possible to construct tables that don’t actually use up all the + /// columns that they could, such as when there are more columns than + /// cells! In this case, a column would have a width of zero. This just + /// checks for that. + pub fn is_complete(&self) -> bool { + self.dimensions.widths.iter().all(|&x| x > 0) + } +} + +impl fmt::Display for Display<'_> { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> Result<(), fmt::Error> { + for y in 0..self.dimensions.num_lines { + for x in 0..self.dimensions.widths.len() { + let num = match self.grid.options.direction { + Direction::LeftToRight => y * self.dimensions.widths.len() + x, + Direction::TopToBottom => y + self.dimensions.num_lines * x, + }; + + // Abandon a line mid-way through if that’s where the cells end + if num >= self.grid.cells.len() { + continue; + } + + let cell = &self.grid.cells[num]; + if x == self.dimensions.widths.len() - 1 { + match cell.alignment { + Alignment::Left => { + // The final column doesn’t need to have trailing spaces, + // as long as it’s left-aligned. + write!(f, "{}", cell.contents)?; + } + Alignment::Right => { + let extra_spaces = self.dimensions.widths[x] - cell.width; + write!( + f, + "{}", + pad_string(&cell.contents, extra_spaces, Alignment::Right) + )?; + } + } + } else { + assert!(self.dimensions.widths[x] >= cell.width); + match (&self.grid.options.filling, cell.alignment) { + (Filling::Spaces(n), Alignment::Left) => { + let extra_spaces = self.dimensions.widths[x] - cell.width + n; + write!( + f, + "{}", + pad_string(&cell.contents, extra_spaces, cell.alignment) + )?; + } + (Filling::Spaces(n), Alignment::Right) => { + let s = spaces(*n); + let extra_spaces = self.dimensions.widths[x] - cell.width; + write!( + f, + "{}{}", + pad_string(&cell.contents, extra_spaces, cell.alignment), + s + )?; + } + (Filling::Text(ref t), _) => { + let extra_spaces = self.dimensions.widths[x] - cell.width; + write!( + f, + "{}{}", + pad_string(&cell.contents, extra_spaces, cell.alignment), + t + )?; + } + } + } + } + + writeln!(f)?; + } + + Ok(()) + } +} + +/// Pad a string with the given number of spaces. +fn spaces(length: usize) -> String { + " ".repeat(length) +} + +/// Pad a string with the given alignment and number of spaces. +/// +/// This doesn’t take the width the string *should* be, rather the number +/// of spaces to add. +fn pad_string(string: &str, padding: usize, alignment: Alignment) -> String { + if alignment == Alignment::Left { + format!("{}{}", string, spaces(padding)) + } else { + format!("{}{}", spaces(padding), string) + } +} + +#[cfg(test)] +mod test { + use super::*; + + #[test] + fn no_items() { + let grid = Grid::new(GridOptions { + direction: Direction::TopToBottom, + filling: Filling::Spaces(2), + }); + + let display = grid.fit_into_width(40).unwrap(); + + assert_eq!(display.dimensions.num_lines, 0); + assert!(display.dimensions.widths.is_empty()); + + assert_eq!(display.width(), 0); + } + + #[test] + fn one_item() { + let mut grid = Grid::new(GridOptions { + direction: Direction::TopToBottom, + filling: Filling::Spaces(2), + }); + + grid.add(Cell::from("1")); + + let display = grid.fit_into_width(40).unwrap(); + + assert_eq!(display.dimensions.num_lines, 1); + assert_eq!(display.dimensions.widths, vec![1]); + + assert_eq!(display.width(), 1); + } + + #[test] + fn one_item_exact_width() { + let mut grid = Grid::new(GridOptions { + direction: Direction::TopToBottom, + filling: Filling::Spaces(2), + }); + + grid.add(Cell::from("1234567890")); + + let display = grid.fit_into_width(10).unwrap(); + + assert_eq!(display.dimensions.num_lines, 1); + assert_eq!(display.dimensions.widths, vec![10]); + + assert_eq!(display.width(), 10); + } + + #[test] + fn one_item_just_over() { + let mut grid = Grid::new(GridOptions { + direction: Direction::TopToBottom, + filling: Filling::Spaces(2), + }); + + grid.add(Cell::from("1234567890!")); + + assert_eq!(grid.fit_into_width(10), None); + } + + #[test] + fn two_small_items() { + let mut grid = Grid::new(GridOptions { + direction: Direction::TopToBottom, + filling: Filling::Spaces(2), + }); + + grid.add(Cell::from("1")); + grid.add(Cell::from("2")); + + let display = grid.fit_into_width(40).unwrap(); + + assert_eq!(display.dimensions.num_lines, 1); + assert_eq!(display.dimensions.widths, vec![1, 1]); + + assert_eq!(display.width(), 1 + 2 + 1); + } + + #[test] + fn two_medium_size_items() { + let mut grid = Grid::new(GridOptions { + direction: Direction::TopToBottom, + filling: Filling::Spaces(2), + }); + + grid.add(Cell::from("hello there")); + grid.add(Cell::from("how are you today?")); + + let display = grid.fit_into_width(40).unwrap(); + + assert_eq!(display.dimensions.num_lines, 1); + assert_eq!(display.dimensions.widths, vec![11, 18]); + + assert_eq!(display.width(), 11 + 2 + 18); + } + + #[test] + fn two_big_items() { + let mut grid = Grid::new(GridOptions { + direction: Direction::TopToBottom, + filling: Filling::Spaces(2), + }); + + grid.add(Cell::from( + "nuihuneihsoenhisenouiuteinhdauisdonhuisudoiosadiuohnteihaosdinhteuieudi", + )); + grid.add(Cell::from( + "oudisnuthasuouneohbueobaugceoduhbsauglcobeuhnaeouosbubaoecgueoubeohubeo", + )); + + assert_eq!(grid.fit_into_width(40), None); + } + + #[test] + fn that_example_from_earlier() { + let mut grid = Grid::new(GridOptions { + filling: Filling::Spaces(1), + direction: Direction::LeftToRight, + }); + + for s in &[ + "one", "two", "three", "four", "five", "six", "seven", "eight", "nine", "ten", + "eleven", "twelve", + ] { + grid.add(Cell::from(*s)); + } + + let bits = "one two three four\nfive six seven eight\nnine ten eleven twelve\n"; + assert_eq!(grid.fit_into_width(24).unwrap().to_string(), bits); + assert_eq!(grid.fit_into_width(24).unwrap().row_count(), 3); + } + + #[test] + fn number_grid_with_pipe() { + let mut grid = Grid::new(GridOptions { + filling: Filling::Text("|".into()), + direction: Direction::LeftToRight, + }); + + for s in &[ + "one", "two", "three", "four", "five", "six", "seven", "eight", "nine", "ten", + "eleven", "twelve", + ] { + grid.add(Cell::from(*s)); + } + + let bits = "one |two|three |four\nfive|six|seven |eight\nnine|ten|eleven|twelve\n"; + assert_eq!(grid.fit_into_width(24).unwrap().to_string(), bits); + assert_eq!(grid.fit_into_width(24).unwrap().row_count(), 3); + } + + #[test] + fn numbers_right() { + let mut grid = Grid::new(GridOptions { + filling: Filling::Spaces(1), + direction: Direction::LeftToRight, + }); + + for s in &[ + "one", "two", "three", "four", "five", "six", "seven", "eight", "nine", "ten", + "eleven", "twelve", + ] { + let mut cell = Cell::from(*s); + cell.alignment = Alignment::Right; + grid.add(cell); + } + + let bits = " one two three four\nfive six seven eight\nnine ten eleven twelve\n"; + assert_eq!(grid.fit_into_width(24).unwrap().to_string(), bits); + assert_eq!(grid.fit_into_width(24).unwrap().row_count(), 3); + } + + #[test] + fn numbers_right_pipe() { + let mut grid = Grid::new(GridOptions { + filling: Filling::Text("|".into()), + direction: Direction::LeftToRight, + }); + + for s in &[ + "one", "two", "three", "four", "five", "six", "seven", "eight", "nine", "ten", + "eleven", "twelve", + ] { + let mut cell = Cell::from(*s); + cell.alignment = Alignment::Right; + grid.add(cell); + } + + let bits = " one|two| three| four\nfive|six| seven| eight\nnine|ten|eleven|twelve\n"; + assert_eq!(grid.fit_into_width(24).unwrap().to_string(), bits); + assert_eq!(grid.fit_into_width(24).unwrap().row_count(), 3); + } + + #[test] + fn huge_separator() { + let mut grid = Grid::new(GridOptions { + filling: Filling::Spaces(100), + direction: Direction::LeftToRight, + }); + + grid.add("a".into()); + grid.add("b".into()); + + assert_eq!(grid.fit_into_width(99), None); + } + + #[test] + fn huge_yet_unused_separator() { + let mut grid = Grid::new(GridOptions { + filling: Filling::Spaces(100), + direction: Direction::LeftToRight, + }); + + grid.add("abcd".into()); + + let display = grid.fit_into_width(99).unwrap(); + + assert_eq!(display.dimensions.num_lines, 1); + assert_eq!(display.dimensions.widths, vec![4]); + + assert_eq!(display.width(), 4); + } +} diff --git a/crates/nu-term-grid/src/lib.rs b/crates/nu-term-grid/src/lib.rs new file mode 100644 index 0000000000..79b146593f --- /dev/null +++ b/crates/nu-term-grid/src/lib.rs @@ -0,0 +1,3 @@ +pub mod grid; + +pub use grid::Grid; diff --git a/crates/nu-term-grid/src/main.rs b/crates/nu-term-grid/src/main.rs new file mode 100644 index 0000000000..a9ef426790 --- /dev/null +++ b/crates/nu-term-grid/src/main.rs @@ -0,0 +1,30 @@ +use nu_term_grid::grid::{Alignment, Cell, Direction, Filling, Grid, GridOptions}; + +// This produces: +// +// 1 | 128 | 16384 | 2097152 | 268435456 | 34359738368 | 4398046511104 +// 2 | 256 | 32768 | 4194304 | 536870912 | 68719476736 | 8796093022208 +// 4 | 512 | 65536 | 8388608 | 1073741824 | 137438953472 | 17592186044416 +// 8 | 1024 | 131072 | 16777216 | 2147483648 | 274877906944 | 35184372088832 +// 16 | 2048 | 262144 | 33554432 | 4294967296 | 549755813888 | 70368744177664 +// 32 | 4096 | 524288 | 67108864 | 8589934592 | 1099511627776 | 140737488355328 +// 64 | 8192 | 1048576 | 134217728 | 17179869184 | 2199023255552 | + +fn main() { + let mut grid = Grid::new(GridOptions { + direction: Direction::TopToBottom, + filling: Filling::Text(" | ".into()), + }); + + for i in 0..48 { + let mut cell = Cell::from(format!("{}", 2_isize.pow(i))); + cell.alignment = Alignment::Right; + grid.add(cell) + } + + if let Some(grid_display) = grid.fit_into_width(80) { + println!("{}", grid_display); + } else { + println!("Couldn't fit grid into 80 columns!"); + } +}