diff --git a/radicle-term/src/textarea.rs b/radicle-term/src/textarea.rs index 110df03e..055711a3 100644 --- a/radicle-term/src/textarea.rs +++ b/radicle-term/src/textarea.rs @@ -1,22 +1,61 @@ -use unicode_width::UnicodeWidthStr; +use crate::{cell::Cell, Element, Line, Paint, Size}; -use crate::{Element, Line, Paint, Size}; +/// Default text wrap width. +pub const DEFAULT_WRAP: usize = 80; /// Text area. /// /// A block of text that can contain multiple lines. #[derive(Debug)] -pub struct TextArea(Paint); +pub struct TextArea { + body: Paint, + wrap: usize, +} impl TextArea { /// Create a new text area. - pub fn new(content: impl Into>) -> Self { - Self(content.into()) + pub fn new(body: impl Into>) -> Self { + Self { + body: body.into(), + wrap: DEFAULT_WRAP, + } + } + + /// Set wrap width. + pub fn wrap(mut self, cols: usize) -> Self { + self.wrap = cols; + self } /// Get the lines of text in this text area. - pub fn lines(&self) -> impl Iterator { - self.0.content().lines() + pub fn lines(&self) -> impl Iterator { + let mut lines: Vec = Vec::new(); + let mut fenced = false; + + for line in self.body.content().lines() { + // Fenced code block support. + if line.starts_with("```") { + fenced = !fenced; + } + // Code blocks are not wrapped, they are truncated. + if fenced || line.starts_with('\t') || line.starts_with(' ') { + lines.push(line.truncate(self.wrap, "…")); + continue; + } + let mut current = String::new(); + + for word in line.split_whitespace() { + if current.width() + word.width() > self.wrap { + lines.push(current.trim_end().to_owned()); + current = word.to_owned(); + } else { + current.push_str(word); + } + current.push(' '); + } + lines.push(current.trim_end().to_owned()); + } + lines.into_iter() } /// Box the text area. @@ -35,7 +74,7 @@ impl Element for TextArea { fn render(&self) -> Vec { self.lines() - .map(|l| Line::new(Paint::new(l).with_style(self.0.style))) + .map(|l| Line::new(Paint::new(l).with_style(self.body.style))) .collect() } } @@ -44,3 +83,119 @@ impl Element for TextArea { pub fn textarea(content: impl Into>) -> TextArea { TextArea::new(content) } + +#[cfg(test)] +mod test { + use super::*; + use pretty_assertions::assert_eq; + + #[test] + fn test_wrapping() { + let t = TextArea::new( + "Radicle enables users to run their own nodes, \ + ensuring censorship-resistant code collaboration \ + and fostering a resilient network without reliance \ + on third-parties.", + ) + .wrap(50); + let wrapped = t.lines().collect::>(); + + assert_eq!( + wrapped, + vec![ + "Radicle enables users to run their own nodes,".to_owned(), + "ensuring censorship-resistant code collaboration".to_owned(), + "and fostering a resilient network without reliance".to_owned(), + "on third-parties.".to_owned(), + ] + ); + } + + #[test] + fn test_wrapping_paragraphs() { + let t = TextArea::new( + "Radicle enables users to run their own nodes, \ + ensuring censorship-resistant code collaboration \ + and fostering a resilient network without reliance \ + on third-parties.\n\n\ + All social artifacts are stored in git, and signed \ + using public-key cryptography. Radicle verifies \ + the authenticity and authorship of all data \ + automatically.", + ) + .wrap(50); + let wrapped = t.lines().collect::>(); + + assert_eq!( + wrapped, + vec![ + "Radicle enables users to run their own nodes,".to_owned(), + "ensuring censorship-resistant code collaboration".to_owned(), + "and fostering a resilient network without reliance".to_owned(), + "on third-parties.".to_owned(), + "".to_owned(), + "All social artifacts are stored in git, and signed".to_owned(), + "using public-key cryptography. Radicle verifies".to_owned(), + "the authenticity and authorship of all data".to_owned(), + "automatically.".to_owned(), + ] + ); + } + + #[test] + fn test_wrapping_code_block() { + let t = TextArea::new( + "\ +Here's an example: + + $ git push rad://z3gqcJUoA1n9HaHKufZs5FCSGazv5/z6MksFqXN3Yhqk8pTJdUGLwATkRfQvwZXPqR2qMEhbS9wzpT + $ rad sync + +Run the above and wait for your project to sync.\ + ", + ) + .wrap(50); + let wrapped = t.lines().collect::>(); + + assert_eq!( + wrapped, + vec![ + "Here's an example:".to_owned(), + "".to_owned(), + " $ git push rad://z3gqcJUoA1n9HaHKufZs5FCSGazv5/…".to_owned(), + " $ rad sync".to_owned(), + "".to_owned(), + "Run the above and wait for your project to sync.".to_owned() + ] + ); + } + + #[test] + fn test_wrapping_fenced_block() { + let t = TextArea::new( + "\ +Here's an example: +``` +$ git push rad://z3gqcJUoA1n9HaHKufZs5FCSGazv5/z6MksFqXN3Yhqk8pTJdUGLwATkRfQvwZXPqR2qMEhbS9wzpT +$ rad sync +``` +Run the above and wait for your project to sync.\ + ", + ) + .wrap(40); + let wrapped = t.lines().collect::>(); + + assert_eq!( + wrapped, + vec![ + "Here's an example:".to_owned(), + "```".to_owned(), + "$ git push rad://z3gqcJUoA1n9HaHKufZs5F…".to_owned(), + "$ rad sync".to_owned(), + "```".to_owned(), + "Run the above and wait for your project".to_owned(), + "to sync.".to_owned() + ] + ); + } +}