From 6fb6cff70825fbac2aef72722181d39c9cd4113e Mon Sep 17 00:00:00 2001 From: Alexis Sellier Date: Sun, 26 Mar 2023 11:31:43 +0200 Subject: [PATCH] term: Add border support for tables --- radicle-cli/examples/rad-node.md | 24 +- radicle-cli/examples/rad-patch.md | 16 +- .../workflow/4-patching-contributor.md | 16 +- radicle-cli/src/commands/node/routing.rs | 8 +- radicle-cli/src/commands/node/tracking.rs | 18 +- radicle-term/src/cell.rs | 7 +- radicle-term/src/element.rs | 18 ++ radicle-term/src/lib.rs | 2 +- radicle-term/src/table.rs | 231 +++++++++++++++--- 9 files changed, 267 insertions(+), 73 deletions(-) diff --git a/radicle-cli/examples/rad-node.md b/radicle-cli/examples/rad-node.md index d0e86735..ae7f4d14 100644 --- a/radicle-cli/examples/rad-node.md +++ b/radicle-cli/examples/rad-node.md @@ -45,9 +45,11 @@ repository that was already created: ``` $ rad node tracking -RID Scope Policy ---- ----- ------ -rad:z42hL2jL4XNk6K8oHQaSWfMgCL7ji trusted track +╭──────────────────────────────────────────────────────╮ +│ RID Scope Policy │ +├──────────────────────────────────────────────────────┤ +│ rad:z42hL2jL4XNk6K8oHQaSWfMgCL7ji trusted track │ +╰──────────────────────────────────────────────────────╯ ``` This is the same as using the `--repos` flag, but if we wish to see @@ -56,9 +58,11 @@ flag: ``` $ rad node tracking --nodes -DID Alias Policy ---- ----- ------ -did:key:z6Mkt67GdsW7715MEfRuP4pSZxJRJh6kj6Y48WRqVv4N1tRk Bob track +╭───────────────────────────────────────────────────────────────────────────╮ +│ DID Alias Policy │ +├───────────────────────────────────────────────────────────────────────────┤ +│ did:key:z6Mkt67GdsW7715MEfRuP4pSZxJRJh6kj6Y48WRqVv4N1tRk Bob track │ +╰───────────────────────────────────────────────────────────────────────────╯ ``` To see the routing table we can use the `rad node routing` command and @@ -68,9 +72,11 @@ created. ``` $ rad node routing -RID NID ---- --- -rad:z42hL2jL4XNk6K8oHQaSWfMgCL7ji z6MknSL…StBU8Vi +╭─────────────────────────────────────────────────────╮ +│ RID NID │ +├─────────────────────────────────────────────────────┤ +│ rad:z42hL2jL4XNk6K8oHQaSWfMgCL7ji z6MknSL…StBU8Vi │ +╰─────────────────────────────────────────────────────╯ ```
diff --git a/radicle-cli/examples/rad-patch.md b/radicle-cli/examples/rad-patch.md index 357b725a..cf4d8412 100644 --- a/radicle-cli/examples/rad-patch.md +++ b/radicle-cli/examples/rad-patch.md @@ -51,14 +51,14 @@ $ rad patch ``` ``` $ rad patch show 191a14e520f2eeff7c0e3ee0a5523c5217eecb89 -╭────────────────────────────────────────────────────────────────────╮ -│ Title Define power requirements │ -│ Patch 191a14e520f2eeff7c0e3ee0a5523c5217eecb89 │ -│ Author did:key:z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi │ -│ Status open │ -│ │ -│ See details. │ -╰────────────────────────────────────────────────────────────────────╯ +╭──────────────────────────────────────────────────────────────────╮ +│ Title Define power requirements │ +│ Patch 191a14e520f2eeff7c0e3ee0a5523c5217eecb89 │ +│ Author did:key:z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi │ +│ Status open │ +│ │ +│ See details. │ +╰──────────────────────────────────────────────────────────────────╯ commit 3e674d1a1df90807e934f9ae5da2591dd6848a33 Author: radicle diff --git a/radicle-cli/examples/workflow/4-patching-contributor.md b/radicle-cli/examples/workflow/4-patching-contributor.md index 85b57998..c93261a0 100644 --- a/radicle-cli/examples/workflow/4-patching-contributor.md +++ b/radicle-cli/examples/workflow/4-patching-contributor.md @@ -49,14 +49,14 @@ $ rad patch │ ● opened by did:key:z6Mkt67GdsW7715MEfRuP4pSZxJRJh6kj6Y48WRqVv4N1tRk (you) 3 months ago │ ╰─────────────────────────────────────────────────────────────────────────────────────────╯ $ rad patch show a07ef7743a32a2e902672ea3526d1db6ee08108a -╭────────────────────────────────────────────────────────────────────╮ -│ Title Define power requirements │ -│ Patch a07ef7743a32a2e902672ea3526d1db6ee08108a │ -│ Author did:key:z6Mkt67GdsW7715MEfRuP4pSZxJRJh6kj6Y48WRqVv4N1tRk │ -│ Status open │ -│ │ -│ See details. │ -╰────────────────────────────────────────────────────────────────────╯ +╭──────────────────────────────────────────────────────────────────╮ +│ Title Define power requirements │ +│ Patch a07ef7743a32a2e902672ea3526d1db6ee08108a │ +│ Author did:key:z6Mkt67GdsW7715MEfRuP4pSZxJRJh6kj6Y48WRqVv4N1tRk │ +│ Status open │ +│ │ +│ See details. │ +╰──────────────────────────────────────────────────────────────────╯ commit 3e674d1a1df90807e934f9ae5da2591dd6848a33 Author: radicle diff --git a/radicle-cli/src/commands/node/routing.rs b/radicle-cli/src/commands/node/routing.rs index d012f83b..488b34ea 100644 --- a/radicle-cli/src/commands/node/routing.rs +++ b/radicle-cli/src/commands/node/routing.rs @@ -4,15 +4,13 @@ use crate::terminal as term; use crate::terminal::Element; pub fn run(routing: &S) -> anyhow::Result<()> { - let mut t = term::Table::new(term::table::TableOptions::default()); + let mut t = term::Table::new(term::table::TableOptions::bordered()); t.push([ term::format::default(String::from("RID")), term::format::default(String::from("NID")), ]); - t.push([ - term::format::default(String::from("---")), - term::format::default(String::from("---")), - ]); + t.divider(); + for (id, node) in routing.entries()? { t.push([ term::format::highlight(id.to_string()), diff --git a/radicle-cli/src/commands/node/tracking.rs b/radicle-cli/src/commands/node/tracking.rs index d2b93b1e..d7e61265 100644 --- a/radicle-cli/src/commands/node/tracking.rs +++ b/radicle-cli/src/commands/node/tracking.rs @@ -15,17 +15,14 @@ pub fn run(store: &tracking::store::Config, mode: TrackingMode) -> anyhow::Resul } fn print_repos(store: &tracking::store::Config) -> anyhow::Result<()> { - let mut t = term::Table::new(term::table::TableOptions::default()); + let mut t = term::Table::new(term::table::TableOptions::bordered()); t.push([ term::format::default(String::from("RID")), term::format::default(String::from("Scope")), term::format::default(String::from("Policy")), ]); - t.push([ - term::format::default(String::from("---")), - term::format::default(String::from("-----")), - term::format::default(String::from("------")), - ]); + t.divider(); + for tracking::Repo { id, scope, policy } in store.repo_policies()? { let id = id.to_string(); let scope = scope.to_string(); @@ -43,17 +40,14 @@ fn print_repos(store: &tracking::store::Config) -> anyhow::Result<()> { } fn print_nodes(store: &tracking::store::Config) -> anyhow::Result<()> { - let mut t = term::Table::new(term::table::TableOptions::default()); + let mut t = term::Table::new(term::table::TableOptions::bordered()); t.push([ term::format::default(String::from("DID")), term::format::default(String::from("Alias")), term::format::default(String::from("Policy")), ]); - t.push([ - term::format::default(String::from("---")), - term::format::default(String::from("-----")), - term::format::default(String::from("------")), - ]); + t.divider(); + for tracking::Node { id, alias, policy } in store.node_policies()? { t.push([ term::format::highlight(Did::from(id).to_string()), diff --git a/radicle-term/src/cell.rs b/radicle-term/src/cell.rs index 8ba9831d..d5572f57 100644 --- a/radicle-term/src/cell.rs +++ b/radicle-term/src/cell.rs @@ -111,7 +111,12 @@ impl Cell for str { boundary += g.len(); cols += c; } - format!("{}{delim}", &self[..boundary]) + // Don't add the delimiter if we just trimmed whitespace. + if self[boundary..].trim().is_empty() { + self[..boundary + 1].to_owned() + } else { + format!("{}{delim}", &self[..boundary]) + } } else { self.to_owned() } diff --git a/radicle-term/src/element.rs b/radicle-term/src/element.rs index 3cdfda0b..772b8ef8 100644 --- a/radicle-term/src/element.rs +++ b/radicle-term/src/element.rs @@ -71,6 +71,13 @@ impl Element for &T { } } +/// Used to specify maximum width or height. +#[derive(Debug, Default, PartialEq, Eq, Clone, Copy)] +pub struct Max { + pub width: Option, + pub height: Option, +} + /// A line of text that has styling and can be displayed. #[derive(Clone, Default, Debug)] pub struct Line { @@ -189,6 +196,17 @@ impl Size { pub fn new(cols: usize, rows: usize) -> Self { Self { cols, rows } } + + /// Limit size. + pub fn limit(mut self, lim: Max) -> Self { + if let Some(w) = lim.width { + self.cols = self.cols.min(w); + } + if let Some(h) = lim.height { + self.rows = self.rows.min(h); + } + self + } } #[cfg(test)] diff --git a/radicle-term/src/lib.rs b/radicle-term/src/lib.rs index eff8f598..bc5d3ce8 100644 --- a/radicle-term/src/lib.rs +++ b/radicle-term/src/lib.rs @@ -16,7 +16,7 @@ pub mod vstack; pub use ansi::Color; pub use ansi::{paint, Paint}; pub use editor::Editor; -pub use element::{Element, Line, Size}; +pub use element::{Element, Line, Max, Size}; pub use hstack::HStack; pub use inquire::ui::Styled; pub use io::*; diff --git a/radicle-term/src/table.rs b/radicle-term/src/table.rs index 0a968a9e..c9e2b8df 100644 --- a/radicle-term/src/table.rs +++ b/radicle-term/src/table.rs @@ -20,17 +20,10 @@ use std::fmt; use crate as term; use crate::cell::Cell; -use crate::{Label, Line, Size}; +use crate::{Color, Label, Line, Max, Paint, Size}; pub use crate::Element; -/// Used to specify maximum width or height. -#[derive(Debug, Default, PartialEq, Eq, Clone, Copy)] -pub struct Max { - width: Option, - height: Option, -} - #[derive(Debug)] pub struct TableOptions { /// Whether the table should be allowed to overflow. @@ -39,6 +32,8 @@ pub struct TableOptions { pub max: Max, /// Horizontal spacing between table cells. pub spacing: usize, + /// Table border. + pub border: Option, } impl Default for TableOptions { @@ -47,15 +42,31 @@ impl Default for TableOptions { overflow: false, max: Max::default(), spacing: 1, + border: None, + } + } +} + +impl TableOptions { + pub fn bordered() -> Self { + Self { + border: Some(term::colors::FAINT), + spacing: 3, + ..Self::default() } } } +#[derive(Debug)] +enum Row { + Data([T; W]), + Divider, +} + #[derive(Debug)] pub struct Table { - rows: Vec<[T; W]>, + rows: Vec>, widths: [usize; W], - width: usize, opts: TableOptions, } @@ -64,7 +75,6 @@ impl Default for Table { Self { rows: Vec::new(), widths: [0; W], - width: 0, opts: TableOptions::default(), } } @@ -80,24 +90,71 @@ where fn render(&self) -> Vec { let mut lines = Vec::new(); - let width = self.opts.max.width.or_else(term::columns); + let limits = self.limits(); + let width = limits.width; + let border = self.opts.border; + let inner = self.inner().limit(limits); + let cols = inner.cols; + + if let Some(color) = border { + lines.push( + Line::default() + .item(Paint::new("╭").fg(color)) + .item(Paint::new("─".repeat(cols)).fg(color)) + .item(Paint::new("╮").fg(color)), + ); + } for row in &self.rows { let mut line = Line::default(); - for (i, cell) in row.iter().enumerate() { - let pad = if i == row.len() - 1 { - 0 - } else { - self.widths[i] + self.opts.spacing - }; - line.push(cell.pad(pad)); - } + match row { + Row::Data(cells) => { + if let Some(color) = border { + line.push(Paint::new("│ ").fg(color)); + } + for (i, cell) in cells.iter().enumerate() { + let pad = if i == cells.len() - 1 { + if border.is_some() { + self.widths[i] + } else { + 0 + } + } else { + self.widths[i] + self.opts.spacing + }; + line.push(cell.pad(pad)); + } - if let Some(width) = width { - line.truncate(width, "…"); - }; - lines.push(line); + if let Some(width) = width { + line.truncate(width, "…"); + } + if let Some(color) = border { + line.push(Paint::new(" │").fg(color)); + } + lines.push(line); + } + Row::Divider => { + if let Some(color) = border { + lines.push( + Line::default() + .item(Paint::new("├").fg(color)) + .item(Paint::new("─".repeat(cols)).fg(color)) + .item(Paint::new("┤").fg(color)), + ); + } else { + lines.push(Line::default()); + } + } + } + } + if let Some(color) = border { + lines.push( + Line::default() + .item(Paint::new("╰").fg(color)) + .item(Paint::new("─".repeat(cols)).fg(color)) + .item(Paint::new("╯").fg(color)), + ); } lines } @@ -108,22 +165,60 @@ impl Table { Self { rows: Vec::new(), widths: [0; W], - width: 0, opts, } } pub fn size(&self) -> Size { - Size::new(self.width, self.rows.len()) + self.outer() + } + + pub fn divider(&mut self) { + self.rows.push(Row::Divider); + } + + pub fn limits(&self) -> Max { + let width = self.opts.max.width.or_else(term::columns).map(|w| { + if self.opts.border.is_some() { + w - 2 + } else { + w + } + }); + Max { + width, + height: None, + } } pub fn push(&mut self, row: [T; W]) { for (i, cell) in row.iter().enumerate() { self.widths[i] = self.widths[i].max(cell.width()); } - self.width = - self.width.max(row.iter().map(Cell::width).sum()) + (W - 1) * self.opts.spacing; - self.rows.push(row); + self.rows.push(Row::Data(row)); + } + + fn inner(&self) -> Size { + let mut cols = self.widths.iter().sum::() + (W - 1) * self.opts.spacing; + let rows = self.rows.len(); + let limits = self.limits(); + + // Account for inner spacing. + if self.opts.border.is_some() { + cols += 2; + } + Size::new(cols, rows).limit(limits) + } + + fn outer(&self) -> Size { + let mut inner = self.inner(); + + // Account for outer borders. + if self.opts.border.is_some() { + inner.cols += 2; + inner.rows += 2; + } + inner } } @@ -164,6 +259,84 @@ mod test { ); } + #[test] + fn test_table_border() { + let mut t = Table::new(TableOptions { + border: Some(Color::Unset), + spacing: 3, + ..TableOptions::default() + }); + + t.push(["Country", "Population", "Code"]); + t.divider(); + t.push(["France", "60M", "FR"]); + t.push(["Switzerland", "7M", "CH"]); + t.push(["Germany", "80M", "DE"]); + + let inner = t.inner(); + assert_eq!(inner.cols, 33); + assert_eq!(inner.rows, 5); + + let outer = t.outer(); + assert_eq!(outer.cols, 35); + assert_eq!(outer.rows, 7); + + assert_eq!( + t.display(), + r#" +╭─────────────────────────────────╮ +│ Country Population Code │ +├─────────────────────────────────┤ +│ France 60M FR │ +│ Switzerland 7M CH │ +│ Germany 80M DE │ +╰─────────────────────────────────╯ +"# + .trim_start() + ); + } + + #[test] + fn test_table_border_truncated() { + let mut t = Table::new(TableOptions { + border: Some(Color::Unset), + spacing: 3, + max: Max { + width: Some(19), + height: None, + }, + ..TableOptions::default() + }); + + t.push(["Code", "Name"]); + t.divider(); + t.push(["FR", "France"]); + t.push(["CH", "Switzerland"]); + t.push(["DE", "Germany"]); + + let inner = t.inner(); + assert_eq!(inner.cols, 17); + assert_eq!(inner.rows, 5); + + let outer = t.outer(); + assert_eq!(outer.cols, 19); + assert_eq!(outer.rows, 7); + + assert_eq!( + t.display(), + r#" +╭─────────────────╮ +│ Code Name │ +├─────────────────┤ +│ FR France │ +│ CH Switzer… │ +│ DE Germany │ +╰─────────────────╯ +"# + .trim_start() + ); + } + #[test] fn test_table_truncate() { let mut t = Table::new(TableOptions {