diff --git a/Cargo.toml b/Cargo.toml index 8ecfa7c027..f11ace55c6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -174,8 +174,13 @@ path = "src/plugins/nu_plugin_extra_s3.rs" required-features = ["s3"] [[bin]] -name = "nu_plugin_extra_chart" -path = "src/plugins/nu_plugin_extra_chart.rs" +name = "nu_plugin_extra_chart_bar" +path = "src/plugins/nu_plugin_extra_chart_bar.rs" +required-features = ["chart"] + +[[bin]] +name = "nu_plugin_extra_chart_line" +path = "src/plugins/nu_plugin_extra_chart_line.rs" required-features = ["chart"] # Main nu binary diff --git a/crates/nu-cli/src/cli.rs b/crates/nu-cli/src/cli.rs index 1fbfa602cd..ffdc32d588 100644 --- a/crates/nu-cli/src/cli.rs +++ b/crates/nu-cli/src/cli.rs @@ -114,6 +114,8 @@ pub fn create_default_context(interactive: bool) -> Result &str { + "chart" + } + + fn signature(&self) -> Signature { + Signature::build("chart") + } + + fn usage(&self) -> &str { + "Displays charts." + } + + async fn run( + &self, + _args: CommandArgs, + registry: &CommandRegistry, + ) -> Result { + if registry.get_command("chart bar").is_none() { + return Err(ShellError::untagged_runtime_error( + "nu_plugin_chart not installed.", + )); + } + + let registry = registry.clone(); + Ok(OutputStream::one(Ok(ReturnSuccess::Value( + UntaggedValue::string(crate::commands::help::get_help(&Chart, ®istry)) + .into_value(Tag::unknown()), + )))) + } +} + +#[cfg(test)] +mod tests { + use super::Chart; + + #[test] + fn examples_work_as_expected() { + use crate::examples::test as test_examples; + + test_examples(Chart {}) + } +} diff --git a/crates/nu-data/src/utils/mod.rs b/crates/nu-data/src/utils/mod.rs index f025b63f69..69cb79429f 100644 --- a/crates/nu-data/src/utils/mod.rs +++ b/crates/nu-data/src/utils/mod.rs @@ -72,7 +72,6 @@ pub fn report( )?; let group_labels = planes.grouping_total(); - let split_labels = planes.splits_total(); let reduced = reduce(&evaluated, options.reduction, &tag)?; @@ -89,7 +88,7 @@ pub fn report( }, Range { start: UntaggedValue::int(0).into_untagged_value(), - end: split_labels, + end: maxima, }, ), data: reduced, @@ -295,7 +294,7 @@ mod tests { }, Range { start: int(0), - end: int(3), + end: int(30), }, ), data: table(&[ diff --git a/crates/nu_plugin_chart/src/main.rs b/crates/nu_plugin_chart/bin/main.rs similarity index 100% rename from crates/nu_plugin_chart/src/main.rs rename to crates/nu_plugin_chart/bin/main.rs diff --git a/crates/nu_plugin_chart/bin/nu_plugin_chart_bar.rs b/crates/nu_plugin_chart/bin/nu_plugin_chart_bar.rs new file mode 100644 index 0000000000..20ada96668 --- /dev/null +++ b/crates/nu_plugin_chart/bin/nu_plugin_chart_bar.rs @@ -0,0 +1,6 @@ +use nu_plugin::serve_plugin; +use nu_plugin_chart::ChartBar; + +fn main() { + serve_plugin(&mut ChartBar::new()); +} diff --git a/crates/nu_plugin_chart/bin/nu_plugin_chart_line.rs b/crates/nu_plugin_chart/bin/nu_plugin_chart_line.rs new file mode 100644 index 0000000000..82ae4a00db --- /dev/null +++ b/crates/nu_plugin_chart/bin/nu_plugin_chart_line.rs @@ -0,0 +1,6 @@ +use nu_plugin::serve_plugin; +use nu_plugin_chart::ChartLine; + +fn main() { + serve_plugin(&mut ChartLine::new()); +} diff --git a/crates/nu_plugin_chart/src/chart.rs b/crates/nu_plugin_chart/src/bar.rs similarity index 72% rename from crates/nu_plugin_chart/src/chart.rs rename to crates/nu_plugin_chart/src/bar.rs index 7244815f41..2e27958870 100644 --- a/crates/nu_plugin_chart/src/chart.rs +++ b/crates/nu_plugin_chart/src/bar.rs @@ -1,52 +1,22 @@ +use nu_data::utils::Model; use nu_errors::ShellError; -use nu_protocol::Value; -use nu_source::Tagged; use tui::{ layout::{Constraint, Direction, Layout}, style::{Color, Modifier, Style}, - widgets::{BarChart as TuiBarChart, Block, Borders}, + widgets::BarChart, }; -pub enum Columns { - One(Tagged), - Two(Tagged, Tagged), - None, -} +const DEFAULT_COLOR: Color = Color::Green; -#[allow(clippy::type_complexity)] -pub struct Chart { - pub reduction: nu_data::utils::Reduction, - pub columns: Columns, - pub eval: Option Result + Send>>, - pub format: Option, -} - -impl Default for Chart { - fn default() -> Self { - Self::new() - } -} - -impl Chart { - pub fn new() -> Chart { - Chart { - reduction: nu_data::utils::Reduction::Count, - columns: Columns::None, - eval: None, - format: None, - } - } -} - -pub struct BarChart<'a> { +pub struct Bar<'a> { pub title: &'a str, pub data: Vec<(&'a str, u64)>, pub enhanced_graphics: bool, } -impl<'a> BarChart<'a> { - pub fn from_model(model: &'a nu_data::utils::Model) -> Result, ShellError> { +impl<'a> Bar<'a> { + pub fn from_model(model: &'a Model) -> Result, ShellError> { let mut data = Vec::new(); let mut data_points = Vec::new(); @@ -109,7 +79,7 @@ impl<'a> BarChart<'a> { } } - Ok(BarChart { + Ok(Bar { title: "Bar Chart", data: (&data[..]).to_vec(), enhanced_graphics: true, @@ -127,15 +97,13 @@ impl<'a> BarChart<'a> { .constraints([Constraint::Percentage(100)].as_ref()) .split(f.size()); - let barchart = TuiBarChart::default() - .block(Block::default().title("Chart").borders(Borders::ALL)) + let barchart = BarChart::default() .data(&self.data) .bar_width(9) - .bar_style(Style::default().fg(Color::Green)) + .bar_style(Style::default().fg(DEFAULT_COLOR)) .value_style( Style::default() - .fg(Color::Black) - .bg(Color::Green) + .bg(Color::Black) .add_modifier(Modifier::BOLD), ); diff --git a/crates/nu_plugin_chart/src/lib.rs b/crates/nu_plugin_chart/src/lib.rs index f094836813..747c5bdcd4 100644 --- a/crates/nu_plugin_chart/src/lib.rs +++ b/crates/nu_plugin_chart/src/lib.rs @@ -1,4 +1,5 @@ -mod chart; +mod bar; +mod line; mod nu; -pub use chart::Chart; +pub use nu::{ChartBar, ChartLine}; diff --git a/crates/nu_plugin_chart/src/line.rs b/crates/nu_plugin_chart/src/line.rs new file mode 100644 index 0000000000..6662a2465d --- /dev/null +++ b/crates/nu_plugin_chart/src/line.rs @@ -0,0 +1,146 @@ +use nu_data::utils::Model; +use nu_errors::ShellError; + +use tui::{ + layout::{Constraint, Direction, Layout}, + style::{Color, Modifier, Style}, + symbols, + text::Span, + widgets::{Axis, Chart, Dataset, GraphType}, +}; + +const DEFAULT_COLOR: Color = Color::Green; + +const DEFAULT_LINE_COLORS: [Color; 5] = [ + Color::Green, + Color::Cyan, + Color::Magenta, + Color::Yellow, + Color::Red, +]; + +#[derive(Debug)] +pub struct Line<'a> { + title: &'a str, + x_labels: Vec, + x_range: [f64; 2], + y_range: [f64; 2], + datasets_names: Vec, + data: Vec>, +} + +impl<'a> Line<'a> { + pub fn from_model(model: &'a Model) -> Result, ShellError> { + Ok(Line { + title: "Line Chart", + x_labels: model.labels.x.to_vec(), + x_range: [ + model.ranges.0.start.as_u64()? as f64, + model.labels.x.len() as f64, + ], + y_range: [ + model.ranges.1.start.as_u64()? as f64, + model.ranges.1.end.as_u64()? as f64, + ], + datasets_names: if model.labels.y.len() == 1 { + vec!["".to_string()] + } else { + model.labels.y.to_vec() + }, + data: model + .data + .table_entries() + .collect::>() + .iter() + .map(|subset| { + subset + .table_entries() + .enumerate() + .map(|(idx, data_point)| { + ( + idx as f64, + if let Ok(point) = data_point.as_u64() { + point as f64 + } else { + 0.0 + }, + ) + }) + .collect::>() + }) + .collect::>(), + }) + } + + pub fn draw(&mut self, ui: &mut tui::Terminal) -> std::io::Result<()> + where + T: tui::backend::Backend, + { + ui.draw(|f| { + let chunks = Layout::default() + .direction(Direction::Vertical) + .margin(1) + .constraints([Constraint::Percentage(100)].as_ref()) + .split(f.size()); + + let x_labels = self + .x_labels + .iter() + .map(move |label| { + Span::styled(label, Style::default().add_modifier(Modifier::BOLD)) + }) + .collect::>(); + + let y_labels = vec![ + Span::styled( + format!("{}", self.y_range[0]), + Style::default().add_modifier(Modifier::BOLD), + ), + Span::raw(format!("{}", (self.y_range[0] + self.y_range[1]) / 2.0)), + Span::styled( + format!("{}", self.y_range[1]), + Style::default().add_modifier(Modifier::BOLD), + ), + ]; + + let marker = if x_labels.len() > 60 { + symbols::Marker::Braille + } else { + symbols::Marker::Dot + }; + + let datasets = self + .data + .iter() + .enumerate() + .map(|(idx, data_series)| { + Dataset::default() + .name(&self.datasets_names[idx]) + .marker(marker) + .graph_type(GraphType::Line) + .style( + Style::default().fg(*DEFAULT_LINE_COLORS + .get(idx) + .unwrap_or_else(|| &DEFAULT_COLOR)), + ) + .data(data_series) + }) + .collect(); + + let chart = Chart::new(datasets) + .x_axis( + Axis::default() + .style(Style::default().fg(Color::Gray)) + .labels(x_labels) + .bounds(self.x_range), + ) + .y_axis( + Axis::default() + .style(Style::default().fg(Color::Gray)) + .labels(y_labels) + .bounds(self.y_range), + ); + f.render_widget(chart, chunks[0]); + }) + } +} diff --git a/crates/nu_plugin_chart/src/nu.rs b/crates/nu_plugin_chart/src/nu.rs new file mode 100644 index 0000000000..7b472a1675 --- /dev/null +++ b/crates/nu_plugin_chart/src/nu.rs @@ -0,0 +1,5 @@ +mod bar; +mod line; + +pub use bar::SubCommand as ChartBar; +pub use line::SubCommand as ChartLine; diff --git a/crates/nu_plugin_chart/src/nu/mod.rs b/crates/nu_plugin_chart/src/nu/bar.rs similarity index 90% rename from crates/nu_plugin_chart/src/nu/mod.rs rename to crates/nu_plugin_chart/src/nu/bar.rs index a99493c2ee..e470762327 100644 --- a/crates/nu_plugin_chart/src/nu/mod.rs +++ b/crates/nu_plugin_chart/src/nu/bar.rs @@ -1,10 +1,11 @@ +use nu_data::utils::{report as build_report, Model}; use nu_errors::ShellError; use nu_plugin::Plugin; use nu_protocol::{CallInfo, ColumnPath, Primitive, Signature, SyntaxShape, UntaggedValue, Value}; -use nu_source::TaggedItem; +use nu_source::{Tagged, TaggedItem}; use nu_value_ext::ValueExt; -use crate::chart::{BarChart, Chart, Columns}; +use crate::bar::Bar; use std::{ error::Error, @@ -27,8 +28,39 @@ enum Event { Tick, } -fn display(model: &nu_data::utils::Model) -> Result<(), Box> { - let mut app = BarChart::from_model(&model)?; +pub enum Columns { + One(Tagged), + Two(Tagged, Tagged), + None, +} + +#[allow(clippy::type_complexity)] +pub struct SubCommand { + pub reduction: nu_data::utils::Reduction, + pub columns: Columns, + pub eval: Option Result + Send>>, + pub format: Option, +} + +impl Default for SubCommand { + fn default() -> Self { + Self::new() + } +} + +impl SubCommand { + pub fn new() -> SubCommand { + SubCommand { + reduction: nu_data::utils::Reduction::Count, + columns: Columns::None, + eval: None, + format: None, + } + } +} + +fn display(model: &Model) -> Result<(), Box> { + let mut app = Bar::from_model(&model)?; enable_raw_mode()?; @@ -94,10 +126,10 @@ fn display(model: &nu_data::utils::Model) -> Result<(), Box> { Ok(()) } -impl Plugin for Chart { +impl Plugin for SubCommand { fn config(&mut self) -> Result { - Ok(Signature::build("chart") - .desc("Displays bar charts") + Ok(Signature::build("chart bar") + .desc("Bar charts") .switch("acc", "accumuate values", Some('a')) .optional( "columns", @@ -131,7 +163,7 @@ impl Plugin for Chart { } } -impl Chart { +impl SubCommand { fn run(&mut self, call_info: CallInfo, input: Vec) -> Result<(), ShellError> { let args = call_info.args; let name = call_info.name_tag; @@ -269,7 +301,7 @@ impl Chart { reduction: &self.reduction, }; - let _ = display(&nu_data::utils::report(&data, options, &name)?); + let _ = display(&build_report(&data, options, &name)?); } Columns::One(col) => { let key = col.clone(); @@ -316,7 +348,7 @@ impl Chart { reduction: &self.reduction, }; - let _ = display(&nu_data::utils::report(&data, options, &name)?); + let _ = display(&build_report(&data, options, &name)?); } _ => {} } diff --git a/crates/nu_plugin_chart/src/nu/line.rs b/crates/nu_plugin_chart/src/nu/line.rs new file mode 100644 index 0000000000..d517ab9861 --- /dev/null +++ b/crates/nu_plugin_chart/src/nu/line.rs @@ -0,0 +1,369 @@ +use nu_data::utils::{report as build_report, Model}; +use nu_errors::ShellError; +use nu_plugin::Plugin; +use nu_protocol::{CallInfo, ColumnPath, Primitive, Signature, SyntaxShape, UntaggedValue, Value}; +use nu_source::{Tagged, TaggedItem}; +use nu_value_ext::ValueExt; + +use crate::line::Line; + +use std::{ + error::Error, + io::{stdout, Write}, + sync::mpsc, + thread, + time::{Duration, Instant}, +}; + +use crossterm::{ + event::{self, DisableMouseCapture, EnableMouseCapture, Event as CEvent, KeyCode}, + execute, + terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen}, +}; + +use tui::{backend::CrosstermBackend, Terminal}; + +enum Event { + Input(I), + Tick, +} + +pub enum Columns { + One(Tagged), + Two(Tagged, Tagged), + None, +} + +#[allow(clippy::type_complexity)] +pub struct SubCommand { + pub reduction: nu_data::utils::Reduction, + pub columns: Columns, + pub eval: Option Result + Send>>, + pub format: Option, +} + +impl Default for SubCommand { + fn default() -> Self { + Self::new() + } +} + +impl SubCommand { + pub fn new() -> SubCommand { + SubCommand { + reduction: nu_data::utils::Reduction::Count, + columns: Columns::None, + eval: None, + format: None, + } + } +} + +fn display(model: &Model) -> Result<(), Box> { + let mut app = Line::from_model(&model)?; + + enable_raw_mode()?; + + let mut stdout = stdout(); + execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?; + + let backend = CrosstermBackend::new(stdout); + + let mut terminal = Terminal::new(backend)?; + + let (tx, rx) = mpsc::channel(); + + let tick_rate = Duration::from_millis(250); + thread::spawn(move || { + let mut last_tick = Instant::now(); + loop { + if event::poll(tick_rate - last_tick.elapsed()).is_ok() { + if let Ok(CEvent::Key(key)) = event::read() { + let _ = tx.send(Event::Input(key)); + } + } + if last_tick.elapsed() >= tick_rate { + let _ = tx.send(Event::Tick); + last_tick = Instant::now(); + } + } + }); + + terminal.clear()?; + + loop { + app.draw(&mut terminal)?; + + match rx.recv()? { + Event::Input(event) => match event.code { + KeyCode::Char('q') => { + disable_raw_mode()?; + execute!( + terminal.backend_mut(), + LeaveAlternateScreen, + DisableMouseCapture + )?; + terminal.show_cursor()?; + break; + } + _ => { + disable_raw_mode()?; + execute!( + terminal.backend_mut(), + LeaveAlternateScreen, + DisableMouseCapture + )?; + terminal.show_cursor()?; + break; + } + }, + Event::Tick => {} + } + } + + Ok(()) +} + +impl Plugin for SubCommand { + fn config(&mut self) -> Result { + Ok(Signature::build("chart line") + .desc("Line charts") + .switch("acc", "accumuate values", Some('a')) + .optional( + "columns", + SyntaxShape::Any, + "the columns to chart [x-axis y-axis]", + ) + .named( + "use", + SyntaxShape::ColumnPath, + "column to use for evaluation", + Some('u'), + ) + .named( + "format", + SyntaxShape::String, + "Specify date and time formatting", + Some('f'), + )) + } + + fn sink(&mut self, call_info: CallInfo, input: Vec) { + if let Some(Value { + value: UntaggedValue::Primitive(Primitive::Boolean(true)), + .. + }) = call_info.args.get("acc") + { + self.reduction = nu_data::utils::Reduction::Accumulate; + } + + let _ = self.run(call_info, input); + } +} + +impl SubCommand { + fn run(&mut self, call_info: CallInfo, input: Vec) -> Result<(), ShellError> { + let args = call_info.args; + let name = call_info.name_tag; + + self.eval = if let Some(path) = args.get("use") { + Some(evaluator(path.as_column_path()?.item)) + } else { + None + }; + + self.format = if let Some(fmt) = args.get("format") { + Some(fmt.as_string()?) + } else { + None + }; + + for arg in args.positional_iter() { + match arg { + Value { + value: UntaggedValue::Primitive(Primitive::String(column)), + tag, + } => { + let column = column.clone(); + self.columns = Columns::One(column.tagged(tag)); + } + Value { + value: UntaggedValue::Table(arguments), + tag, + } => { + if arguments.len() > 1 { + let col1 = arguments + .get(0) + .ok_or_else(|| { + ShellError::labeled_error( + "expected file and replace strings eg) [find replace]", + "missing find-replace values", + tag, + ) + })? + .as_string()? + .tagged(tag); + + let col2 = arguments + .get(1) + .ok_or_else(|| { + ShellError::labeled_error( + "expected file and replace strings eg) [find replace]", + "missing find-replace values", + tag, + ) + })? + .as_string()? + .tagged(tag); + + self.columns = Columns::Two(col1, col2); + } else { + let col1 = arguments + .get(0) + .ok_or_else(|| { + ShellError::labeled_error( + "expected file and replace strings eg) [find replace]", + "missing find-replace values", + tag, + ) + })? + .as_string()? + .tagged(tag); + + self.columns = Columns::One(col1); + } + } + _ => {} + } + } + + let data = UntaggedValue::table(&input).into_value(&name); + + match &self.columns { + Columns::Two(col1, col2) => { + let key = col1.clone(); + let fmt = self.format.clone(); + + let grouper = Box::new(move |_: usize, row: &Value| { + let key = key.clone(); + let fmt = fmt.clone(); + + match row.get_data_by_key(key.borrow_spanned()) { + Some(key) => { + if let Some(fmt) = fmt { + let callback = nu_data::utils::helpers::date_formatter(fmt); + callback(&key, "nothing".to_string()) + } else { + nu_value_ext::as_string(&key) + } + } + None => Err(ShellError::labeled_error( + "unknown column", + "unknown column", + key.tag(), + )), + } + }); + + let key = col2.clone(); + let splitter = Box::new(move |_: usize, row: &Value| { + let key = key.clone(); + + match row.get_data_by_key(key.borrow_spanned()) { + Some(key) => nu_value_ext::as_string(&key), + None => Err(ShellError::labeled_error( + "unknown column", + "unknown column", + key.tag(), + )), + } + }); + + let formatter = if self.format.is_some() { + let default = String::from("%b-%Y"); + + let string_fmt = self.format.as_ref().unwrap_or_else(|| &default); + + Some(nu_data::utils::helpers::date_formatter( + string_fmt.to_string(), + )) + } else { + None + }; + + let options = nu_data::utils::Operation { + grouper: Some(grouper), + splitter: Some(splitter), + format: &formatter, + eval: &self.eval, + reduction: &self.reduction, + }; + + let _ = display(&build_report(&data, options, &name)?); + } + Columns::One(col) => { + let key = col.clone(); + let fmt = self.format.clone(); + + let grouper = Box::new(move |_: usize, row: &Value| { + let key = key.clone(); + let fmt = fmt.clone(); + + match row.get_data_by_key(key.borrow_spanned()) { + Some(key) => { + if let Some(fmt) = fmt { + let callback = nu_data::utils::helpers::date_formatter(fmt); + callback(&key, "nothing".to_string()) + } else { + nu_value_ext::as_string(&key) + } + } + None => Err(ShellError::labeled_error( + "unknown column", + "unknown column", + key.tag(), + )), + } + }); + + let formatter = if self.format.is_some() { + let default = String::from("%b-%Y"); + + let string_fmt = self.format.as_ref().unwrap_or_else(|| &default); + + Some(nu_data::utils::helpers::date_formatter( + string_fmt.to_string(), + )) + } else { + None + }; + + let options = nu_data::utils::Operation { + grouper: Some(grouper), + splitter: None, + format: &formatter, + eval: &self.eval, + reduction: &self.reduction, + }; + + let _ = display(&build_report(&data, options, &name)?); + } + _ => {} + } + + Ok(()) + } +} + +pub fn evaluator(by: ColumnPath) -> Box Result + Send> { + Box::new(move |_: usize, value: &Value| { + let path = by.clone(); + + let eval = nu_value_ext::get_data_by_column_path(value, &path, move |_, _, error| error); + + match eval { + Ok(with_value) => Ok(with_value), + Err(reason) => Err(reason), + } + }) +} diff --git a/src/plugins/nu_plugin_extra_chart.rs b/src/plugins/nu_plugin_extra_chart.rs deleted file mode 100644 index f7349207fe..0000000000 --- a/src/plugins/nu_plugin_extra_chart.rs +++ /dev/null @@ -1,6 +0,0 @@ -use nu_plugin::serve_plugin; -use nu_plugin_chart::Chart; - -fn main() { - serve_plugin(&mut Chart::new()); -} diff --git a/src/plugins/nu_plugin_extra_chart_bar.rs b/src/plugins/nu_plugin_extra_chart_bar.rs new file mode 100644 index 0000000000..20ada96668 --- /dev/null +++ b/src/plugins/nu_plugin_extra_chart_bar.rs @@ -0,0 +1,6 @@ +use nu_plugin::serve_plugin; +use nu_plugin_chart::ChartBar; + +fn main() { + serve_plugin(&mut ChartBar::new()); +} diff --git a/src/plugins/nu_plugin_extra_chart_line.rs b/src/plugins/nu_plugin_extra_chart_line.rs new file mode 100644 index 0000000000..82ae4a00db --- /dev/null +++ b/src/plugins/nu_plugin_extra_chart_line.rs @@ -0,0 +1,6 @@ +use nu_plugin::serve_plugin; +use nu_plugin_chart::ChartLine; + +fn main() { + serve_plugin(&mut ChartLine::new()); +}