From 71c00471bc72a8b8947666a96704b47d28c1571e Mon Sep 17 00:00:00 2001 From: Jonathan Sambrook Date: Sun, 4 Feb 2024 13:33:00 +0000 Subject: [PATCH 1/5] Pull in plotters' default features in Cargo.toml The examples file will not compile without this. --- Cargo.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Cargo.toml b/Cargo.toml index b06bd82..00d7a16 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -12,7 +12,7 @@ description = "Ratatui widget to draw a plotters chart" plotters-backend = "0.3.5" ratatui = "0.26.0" thiserror = "1.0.50" -plotters = {version = "0.3.5", optional = true, default-features = false} +plotters = {version = "0.3.5", optional = true, default-features = true} log = "0.4.20" [features] From 3c5f8216838471e821de6ad9cb579e7790deabf2 Mon Sep 17 00:00:00 2001 From: Jonathan Sambrook Date: Sun, 4 Feb 2024 13:43:48 +0000 Subject: [PATCH 2/5] Make CHAR_PIXEL_SIZE account for different height and width --- src/lib.rs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/lib.rs b/src/lib.rs index 932c4d6..2e4a66e 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -10,7 +10,7 @@ mod widget; #[cfg(feature = "widget")] pub use widget::*; -pub const CHAR_PIXEL_SIZE: u32 = 4; +pub const CHAR_PIXEL_SIZE: (u32, u32) = (2, 4); pub struct RatatuiBackend<'a, 'b> { pub canvas: &'a mut canvas::Context<'b>, @@ -41,7 +41,7 @@ impl<'a, 'b> DrawingBackend for RatatuiBackend<'a, 'b> { mut coord: BackendCoord, ) -> Result { let width = text.chars().count(); - coord.0 -= (width as u32 * CHAR_PIXEL_SIZE / 2) as i32; + coord.0 -= (width as u32 * CHAR_PIXEL_SIZE.0 / 2) as i32; let (x, y) = backend_to_canvas_coords(coord, self.size); self.canvas.print(x, y, text::Line::styled(text.to_string(), convert_style(style))); @@ -103,12 +103,12 @@ impl<'a, 'b> DrawingBackend for RatatuiBackend<'a, 'b> { text: &str, _style: &TStyle, ) -> std::result::Result<(u32, u32), DrawingErrorKind> { - Ok((text.chars().count() as u32 * CHAR_PIXEL_SIZE, CHAR_PIXEL_SIZE)) + Ok((text.chars().count() as u32 * CHAR_PIXEL_SIZE.0, CHAR_PIXEL_SIZE.1)) } } fn rect_to_size(rect: layout::Rect) -> (u32, u32) { - (u32::from(rect.width) * CHAR_PIXEL_SIZE, u32::from(rect.height) * CHAR_PIXEL_SIZE) + (u32::from(rect.width) * CHAR_PIXEL_SIZE.0, u32::from(rect.height) * CHAR_PIXEL_SIZE.1) } fn backend_to_canvas_coords((x, y): BackendCoord, rect: layout::Rect) -> (f64, f64) { From 5e1ce9f28f8d1678680a1dec328a39fd2a44d530 Mon Sep 17 00:00:00 2001 From: Jonathan Sambrook Date: Sun, 21 Jan 2024 22:07:53 +0000 Subject: [PATCH 3/5] Implement fill for circles and rectangles --- src/lib.rs | 64 +++++++++++++++++++++++++++++++++++++++--------------- 1 file changed, 46 insertions(+), 18 deletions(-) diff --git a/src/lib.rs b/src/lib.rs index 2e4a66e..cc077ba 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -66,15 +66,27 @@ impl<'a, 'b> DrawingBackend for RatatuiBackend<'a, 'b> { coord: BackendCoord, radius: u32, style: &S, - _fill: bool, + fill: bool, ) -> std::result::Result<(), DrawingErrorKind> { let (x, y) = backend_to_canvas_coords(coord, self.size); - self.canvas.draw(&canvas::Circle { - x, - y, - radius: radius.into(), - color: convert_color(style.color()), - }); + if fill { + let radius = radius as i32; + for dy in -radius..=radius { + let half_width = ((radius.pow(2) - dy.pow(2)) as f64).sqrt() as i32; + self.draw_line( + (coord.0 - half_width, coord.1 + dy), + (coord.0 + half_width, coord.1 + dy), + style, + )?; + } + } else { + self.canvas.draw(&canvas::Circle { + x, + y, + radius: radius.into(), + color: convert_color(style.color()), + }); + } Ok(()) } @@ -83,18 +95,34 @@ impl<'a, 'b> DrawingBackend for RatatuiBackend<'a, 'b> { coord1: BackendCoord, coord2: BackendCoord, style: &S, - _fill: bool, + fill: bool, ) -> std::result::Result<(), DrawingErrorKind> { - let (x1, y1) = backend_to_canvas_coords(coord1, self.size); - let (x2, y2) = backend_to_canvas_coords(coord2, self.size); - - self.canvas.draw(&canvas::Rectangle { - x: x1.min(x2), - y: y1.min(y2), - width: (x2 - x1).abs(), - height: (y2 - y1).abs(), - color: convert_color(style.color()), - }); + if fill { + let color = convert_color(style.color()); + + let (start, stop) = ( + (coord1.0.min(coord2.0), coord1.1.min(coord2.1)), + (coord1.0.max(coord2.0), coord1.1.max(coord2.1)), + ); + + for x in start.0..=stop.0 { + let (x1, y1) = backend_to_canvas_coords((x, start.1), self.size); + let (x2, y2) = backend_to_canvas_coords((x, stop.1), self.size); + + self.canvas.draw(&canvas::Line::new(x1, y1, x2, y2, color)); + } + } else { + let (x1, y1) = backend_to_canvas_coords(coord1, self.size); + let (x2, y2) = backend_to_canvas_coords(coord2, self.size); + + self.canvas.draw(&canvas::Rectangle { + x: x1.min(x2), + y: y1.min(y2), + width: (x2 - x1).abs(), + height: (y2 - y1).abs(), + color: convert_color(style.color()), + }); + } Ok(()) } From 3cea9e08a52f8a55935533f4d43253e46aa72656 Mon Sep 17 00:00:00 2001 From: Jonathan Sambrook Date: Sun, 4 Feb 2024 13:38:12 +0000 Subject: [PATCH 4/5] Add tests. Note: these require human interaction --- README.md | 8 ++ tests/test.rs | 377 ++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 385 insertions(+) create mode 100644 tests/test.rs diff --git a/README.md b/README.md index 288c16c..6551618 100644 --- a/README.md +++ b/README.md @@ -7,3 +7,11 @@ A ratatui widget for drawing a plotters chart. + +## Tests + +When running tests please note the following constraints. + +The tests *require* human interaction. An image is displayed in the upper section of the terminal, with a yes/no question in the lower section. Inspect the image, and respond to the question with 'y' or 'n' as appropriate. + +Because the tests require a `ratatui` display loop, they **MUST** be run single threaded `--test-threads=1`, e.g. `cargo test -- --test-threads=1`. diff --git a/tests/test.rs b/tests/test.rs new file mode 100644 index 0000000..9631110 --- /dev/null +++ b/tests/test.rs @@ -0,0 +1,377 @@ +use std::io; +use std::time::Duration; + +use crossterm::event::{Event, KeyCode, KeyModifiers}; +use crossterm::terminal::{ + disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen, +}; +use crossterm::{event, ExecutableCommand}; +use plotters::coord::Shift; +//use plotters::prelude::{ChartBuilder, DrawingArea, LabelAreaPosition, SeriesLabelPosition, *}; +use plotters::prelude::{DrawingArea, *}; +//use plotters::series::LineSeries; +//use plotters::style::{IntoTextStyle as _, RGBColor}; +use plotters_ratatui_backend::{widget_fn, AreaResult, RatatuiBackend}; +use ratatui::prelude::{Alignment, Constraint, CrosstermBackend, Direction, Layout}; +use ratatui::widgets::{Block, BorderType, Borders, Padding, Paragraph}; +use ratatui::Terminal; + +// ----------------------------------------------------------------------------- + +const CHAR_PIXEL_SIZE: (i32, i32) = (2, 4); + +// ----------------------------------------------------------------------------- + +fn quit() -> anyhow::Result<()> { + io::stdout().execute(LeaveAlternateScreen)?; + disable_raw_mode()?; + eprintln!("User requested to immediately exit tests."); + std::process::exit(-1); +} + +// ----------------------------------------------------------------------------- + +fn render( + test_name: &str, + description: impl ToString, + draw_fn: impl Fn(DrawingArea, Shift>) -> AreaResult, +) -> anyhow::Result { + let description = description.to_string().replace("\n", "\n "); + let question = format!( + "Does the description:\n\n '{description}'\n\nmatch what's displayed above? Press 'y' \ + or 'n'", + ); + + let title = format!(" {test_name} "); + + let para = Paragraph::new(question).alignment(Alignment::Left).block( + Block::default() + .title(title) + .padding(Padding::uniform(1)) + .borders(Borders::ALL) + .border_type(BorderType::Rounded), + ); + + enable_raw_mode()?; + io::stdout().execute(EnterAlternateScreen)?; + + let mut terminal = Terminal::new(CrosstermBackend::new(io::stdout()))?; + + terminal.draw(|frame| { + let rects = + Layout::new(Direction::Vertical, [Constraint::Ratio(4, 5), Constraint::Ratio(1, 5)]) + .split(frame.size()); + + frame.render_widget(widget_fn(&draw_fn), rects[0]); + frame.render_widget(para, rects[1]); + })?; + + #[allow(unused_assignments)] + let mut retval = false; + + loop { + if event::poll(Duration::from_secs(1))? { + match event::read()? { + Event::Key(key) => match key.code { + KeyCode::Char('n') => { + retval = false; + break; + } + KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => quit()?, + KeyCode::Char('q') => quit()?, + KeyCode::Char('y') => { + retval = true; + break; + } + _ => (), + }, + _ => (), + } + } + } + + io::stdout().execute(LeaveAlternateScreen)?; + disable_raw_mode()?; + + Ok(retval) +} + +// ----------------------------------------------------------------------------- + +#[test] +fn test_path_element_line() { + assert_eq!( + true, + render( + "PathElement thin", + "A red slash from the upper left to lower right corner. The line is a single pixel.", + |area| { + let (x, y) = area.dim_in_pixel(); + let points = vec![(0, 0), (x as i32, y as i32)]; + area.draw(&PathElement::new(points, RED.stroke_width(1))).unwrap(); + area.present().unwrap(); + Ok(()) + } + ) + .unwrap() + ); +} + +// ----------------------------------------------------------------------------- + +#[test] +fn test_path_element_block() { + assert_eq!( + true, + render( + "PathElement thick", + "A red slash from the upper left to lower right corner, on a white \ + background.\nBecause of the char scope of colors, the line is drawn in jaggy \ + character blocks.", + |area| { + let (x, y) = area.dim_in_pixel(); + area.fill(&WHITE).unwrap(); + let points = vec![(0, 0), (x as i32, y as i32)]; + area.draw(&PathElement::new(points, RED.stroke_width(1))).unwrap(); + area.present().unwrap(); + Ok(()) + } + ) + .unwrap() + ); +} + +// ----------------------------------------------------------------------------- + +#[test] +fn test_circle_unfilled() { + assert_eq!( + true, + render("Unfilled Circle", "A red bordered empty circle", |area| { + let (x, y) = area.dim_in_pixel(); + let (x, y) = (x as i32 / 2, y as i32 / 2); + area.draw(&Circle::new((x, y), x.min(y), RED)).unwrap(); + area.present().unwrap(); + Ok(()) + }) + .unwrap() + ); +} + +// ----------------------------------------------------------------------------- + +#[test] +fn test_circle_filled() { + assert_eq!( + true, + render("Filled Circle", "A filled in red circle", |area| { + let (x, y) = area.dim_in_pixel(); + let (x, y) = (x as i32 / 2, y as i32 / 2); + area.draw(&Circle::new((x, y), x.min(y), RED.filled())).unwrap(); + area.present().unwrap(); + Ok(()) + }) + .unwrap() + ); +} + +// ----------------------------------------------------------------------------- + +#[test] +fn test_circle_concentric_filled() { + assert_eq!( + true, + render( + "Concentric Filled Circles", + "A multicolored series of concentric filled circles. The outer circle is expected to \ + be a closer approximation to circular, but given the discrete nature of character \ + cells with respect to color, the inner \"circles\" will be extremely jaggy.", + |area| { + let (x, y) = area.dim_in_pixel(); + let (x, y) = (x as i32 / 2, y as i32 / 2); + + let colors = [RED, GREEN, BLUE, MAGENTA, CYAN, YELLOW]; + for (r, color) in (1..x.min(y)).step_by(10).rev().zip(colors.iter().cycle()) { + area.draw(&Circle::new((x, y), r, color.filled())).unwrap(); + } + area.present().unwrap(); + Ok(()) + } + ) + .unwrap() + ); +} + +// ----------------------------------------------------------------------------- + +#[test] +fn test_circle_concentric_unfilled() { + assert_eq!( + true, + render("Concentric Circles", "A series of concentric circles", |area| { + let (x, y) = area.dim_in_pixel(); + let (x, y) = (x as i32 / 2, y as i32 / 2); + for r in (1..x.min(y)).step_by(10) { + area.draw(&Circle::new((x, y), r, RED)).unwrap(); + } + area.present().unwrap(); + Ok(()) + }) + .unwrap() + ); +} + +// ----------------------------------------------------------------------------- + +#[test] +fn test_square_filled() { + assert_eq!( + true, + render("Filled Rect", "A filled in red square in the centre of the display area", |area| { + let (x, y) = area.dim_in_pixel(); + let (x, y) = area.map_coordinate(&(x as i32, y as i32)); + + let mid = (x as i32 / 2, y as i32 / 2); + let n = x.min(y) as i32 / 4; + + area.draw(&Rectangle::new( + [(mid.0 - n, mid.1 - n), (mid.0 + n, mid.1 + n)], + RED.filled(), + )) + .unwrap(); + + area.present().unwrap(); + Ok(()) + }) + .unwrap() + ); +} + +// ----------------------------------------------------------------------------- + +#[test] +fn test_square_unfilled() { + assert_eq!( + true, + render( + "Unfilled Square", + "A red bordered empty square in the center of the display area", + |area| { + let (x, y) = area.dim_in_pixel(); + let (x, y) = area.map_coordinate(&(x as i32, y as i32)); + + let n = x.min(y) as i32 / 4; + let mid = (x as i32 / 2, y as i32 / 2); + + area.draw(&Rectangle::new([(mid.0 - n, mid.1 - n), (mid.0 + n, mid.1 + n)], RED)) + .unwrap(); + + area.present().unwrap(); + Ok(()) + } + ) + .unwrap() + ); +} + +// ----------------------------------------------------------------------------- + +#[test] +fn test_square_unfilled_inverted_coords() { + assert_eq!( + true, + render( + "Unfilled squares from inverted coords", + "A red unfilled square in the center of the display area.\nBlue, green, yellow, and \ + cyan unfilled squares to the upper left, upper right, lower left, and lower right \ + respectively.", + |area| { + let (x, y) = area.dim_in_pixel(); + let (x, y) = (x as i32, y as i32); + + let n = x.min(y) / 8; + let mid = (x / 2, y / 2); + let ul = (x / 4, y / 4); + let ur = (x * 3 / 4, y / 4); + let ll = (x / 4, y * 3 / 4); + let lr = (x * 3 / 4, y * 3 / 4); + + let rects = [(ul, BLUE), (ur, GREEN), (mid, RED), (ll, YELLOW), (lr, CYAN)]; + + for ((x, y), color) in rects { + area.draw(&Rectangle::new([(x + n, y + n), (x - n, y - n)], color)).unwrap(); + } + + area.present().unwrap(); + Ok(()) + } + ) + .unwrap() + ); +} + +// ----------------------------------------------------------------------------- + +#[test] +fn test_square_filled_and_unfilled() { + assert_eq!( + true, + render( + "Filled and Unfilled squares are same size", + "Filled green (upper), blue (lower), yellow (left), and cyan (right) \ + squares,\ninterspersed with white bordered unfilled squares.\nTouching edges are the \ + same length. All enclosed inside a red border.", + |area| { + let (x, y) = area.dim_in_pixel(); + + // Midpoint of area + let (x, y) = (x as i32 / 2, y as i32 / 2); + + // Scoot along to char boundary + let (x, y) = (x + x % CHAR_PIXEL_SIZE.0 + 1, y + y % CHAR_PIXEL_SIZE.1 + 1); + + // Unit length is defined in multiples of char height (4), which conveniently is a multiple of char width (2). + let n = 3 * CHAR_PIXEL_SIZE.1; + + let base = [(x, y), (x + n - 1, y + n - 1)]; + + let t = [(base[0].0, base[0].1 - n), (base[1].0, base[1].1 - n)]; + let b = [(base[0].0, base[0].1 + n), (base[1].0, base[1].1 + n)]; + let l = [(base[0].0 - n, base[0].1), (base[1].0 - n, base[1].1)]; + let r = [(base[0].0 + n, base[0].1), (base[1].0 + n, base[1].1)]; + + let tl = [(base[0].0 - n, base[0].1 - n), (base[1].0 - n, base[1].1 - n)]; + let bl = [(base[0].0 - n, base[0].1 + n), (base[1].0 - n, base[1].1 + n)]; + let tr = [(base[0].0 + n, base[0].1 - n), (base[1].0 + n, base[1].1 - n)]; + let br = [(base[0].0 + n, base[0].1 + n), (base[1].0 + n, base[1].1 + n)]; + + let border = [(tl[0].0 - 1, tl[0].1 - 1), (br[0].0 + n, br[1].1 + 1)]; + + let white = WHITE.stroke_width(1); + + let rects = [ + (base, white), + (t, GREEN.filled()), + (b, BLUE.filled()), + (l, YELLOW.filled()), + (r, CYAN.filled()), + (tl, white), + (bl, white), + (tr, white), + (br, white), + (border, RED.stroke_width(1)), + ]; + + for (coords, color) in rects { + area.draw(&Rectangle::new(coords, color)).unwrap(); + } + + area.present().unwrap(); + Ok(()) + } + ) + .unwrap() + ); +} + +// ----------------------------------------------------------------------------- From 8a987919cbdba729196bc9c3d897bd03d7ffc889 Mon Sep 17 00:00:00 2001 From: Jonathan Sambrook Date: Sun, 4 Feb 2024 14:10:54 +0000 Subject: [PATCH 5/5] Add main to boilerplate to keep cargo happy. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit After rebasing against recent master changes, running `cargo test` elicits the following error: ❯ cargo test -- --test-threads=1 --nocapture Compiling libm v0.2.8 Compiling num-traits v0.2.17 Compiling getrandom v0.2.12 Compiling strum_macros v0.26.1 Compiling castaway v0.2.2 Compiling static_assertions v1.1.0 Compiling ppv-lite86 v0.2.17 Compiling itoa v1.0.10 Compiling ryu v1.0.16 Compiling rand_core v0.6.4 Compiling compact_str v0.7.1 Compiling rand_xorshift v0.3.0 Compiling rand_chacha v0.3.1 Compiling rand v0.8.5 Compiling image v0.24.8 Compiling chrono v0.4.33 Compiling rand_distr v0.4.3 Compiling strum v0.26.1 Compiling ratatui v0.26.0 Compiling flexi_logger v0.27.4 Compiling plotters-bitmap v0.3.3 Compiling plotters v0.3.5 Compiling plotters-ratatui-backend v0.1.3 (/home/ebardie/src/bytecounter/plotters-ratatui-backend) error[E0601]: `main` function not found in crate `boilerplate` --> plotters-ratatui-backend/examples/boilerplate.rs:54:2 | 54 | } | ^ consider adding a `main` function to `plotters-ratatui-backend/examples/boilerplate.rs` For more information about this error, try `rustc --explain E0601`. error: could not compile `plotters-ratatui-backend` (example "boilerplate") due to 1 previous error warning: build failed, waiting for other jobs to finish... This commit adds a dummy main to `examples/boilerplate.rs`, which keeps `cargo` happy. --- examples/boilerplate.rs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/examples/boilerplate.rs b/examples/boilerplate.rs index ad1ab7e..6fc78f5 100644 --- a/examples/boilerplate.rs +++ b/examples/boilerplate.rs @@ -52,3 +52,7 @@ pub fn main_boilerplate( Ok(()) } + +#[warn(dead_code)] +fn main() {} +