diff --git a/Cargo.toml b/Cargo.toml index 5e440b9..367708c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -15,6 +15,8 @@ chrono-tz = "0.5" time = "0.1.42" structopt = "0.2.10" rustyline = "5.0.0" +tui = "0.5" +termion = "1.5" [profile.release] lto = true diff --git a/src/core_functions.rs b/src/core_functions.rs index 890e5e0..a54f2f4 100644 --- a/src/core_functions.rs +++ b/src/core_functions.rs @@ -57,6 +57,7 @@ pub struct ImportProcessParameters { pub input_file_date_separator: String, pub input_file_has_iso_date_style: bool, pub should_export: bool, + pub print_menu: bool, } pub(crate) fn import_and_process_final( diff --git a/src/main.rs b/src/main.rs index 2b1d622..f064211 100644 --- a/src/main.rs +++ b/src/main.rs @@ -9,9 +9,24 @@ use std::ffi::OsString; -use structopt::StructOpt; use std::path::PathBuf; use std::error::Error; +use std::io; +use std::sync::mpsc; +use std::thread; +use std::time::Duration; + +use tui::Terminal; +use tui::backend::{TermionBackend, Backend}; +use tui::style::{Color, Modifier, Style}; +use tui::widgets::{Widget, Block, Borders, SelectableList, Text, Paragraph}; +use tui::layout::{Layout, Constraint, Direction}; +use termion::raw::IntoRawMode; +use termion::screen::AlternateScreen; +use termion::input::MouseTerminal; +use termion::event::Key; +use termion::input::TermRead; +use structopt::StructOpt; mod account; mod transaction; @@ -60,6 +75,11 @@ pub(crate) struct Flags { #[structopt(name = "date conforms to ISO 8601", short = "i", long = "iso")] iso_date: bool, + /// Once the import file has been fully processed, the user will be presented + /// with a menu for manually selecting which reports to print/export. + #[structopt(name = "print menu", short, long = "print-menu")] + print_menu: bool, + /// This will prevent the program from writing reports to files. /// This will be ignored if -a is not set (the wizard will always ask to output). #[structopt(name = "suppress reports", short, long = "suppress")] @@ -125,9 +145,12 @@ fn main() -> Result<(), Box> { transactions_map, ) = core_functions::import_and_process_final(input_file_path, &settings)?; - let should_export = settings.should_export; + let mut should_export_all = settings.should_export; + let present_print_menu_tui = settings.print_menu; - if should_export { + if present_print_menu_tui { should_export_all = false } + + if should_export_all { println!("Creating reports now."); @@ -195,6 +218,340 @@ fn main() -> Result<(), Box> { } + if present_print_menu_tui { + + const TASKS: [&'static str; 9] = [ + "1. CSV: Account Sums", + "2. CSV: Account Sums (Non-zero only)", + "3. CSV: Account Sums (Orig. basis vs like-kind basis)", + "4. CSV: Transactions by movement (every movement)", + "5. CSV: Transactions by movement (summarized by long-term/short-term)", + "6. CSV: Transactions by movement (every movement, w/ orig. and like-kind basis", + "7. TXT: Accounts by lot (every movement)", + "8. TXT: Accounts by lot (every lot balance)", + "9. TXT: Accounts by lot (every non-zero lot balance)", + ]; + + pub struct ListState { + pub items: Vec, + pub selected: usize, + } + + impl ListState { + fn new(items: Vec) -> ListState { + ListState { items, selected: 0 } + } + fn select_previous(&mut self) { + if self.selected > 0 { + self.selected -= 1; + } + } + fn select_next(&mut self) { + if self.selected < self.items.len() - 1 { + self.selected += 1 + } + } + } + + pub struct PrintWindow<'a> { + pub title: &'a str, + pub should_quit: bool, + pub tasks: ListState<(&'a str)>, + pub to_print: Vec, + } + + impl<'a> PrintWindow<'a> { + pub fn new(title: &'a str) -> PrintWindow<'a> { + PrintWindow { + title, + should_quit: false, + tasks: ListState::new(TASKS.to_vec()), + to_print: Vec::with_capacity(TASKS.len() + 3), + } + } + + pub fn on_up(&mut self) { + self.tasks.select_previous(); + } + + pub fn on_down(&mut self) { + self.tasks.select_next(); + } + + pub fn on_key(&mut self, c: char) { + match c { + 'q' => { + self.should_quit = true; + self.to_print = Vec::with_capacity(0) + } + 'p' => { + Self::change_vec_to_chrono_order_and_dedup(&mut self.to_print); + self.should_quit = true; + } + 'x' => { + self.to_print.push(self.tasks.selected) + } + _ => {} + } + } + fn change_vec_to_chrono_order_and_dedup(vec: &mut Vec) { + let length = vec.len(); + for _ in 0..length { + for j in 0..length-1 { + if vec[j] > vec[j+1] { + vec.swap(j, j+1) + } + + } + } + vec.dedup(); + } + } + + let stdout = io::stdout().into_raw_mode()?; + let stdout = MouseTerminal::from(stdout); + let stdout = AlternateScreen::from(stdout); + let backend = TermionBackend::new(stdout); + let mut terminal = Terminal::new(backend)?; + terminal.hide_cursor()?; + + pub fn draw(terminal: &mut Terminal, app: &PrintWindow) -> Result<(), io::Error> { + terminal.draw(|mut f| { + let chunks = Layout::default() + .constraints([ + Constraint::Length(1), + Constraint::Length(8), + Constraint::Length(TASKS.len() as u16 + 2), + Constraint::Percentage(35) + ].as_ref()) + .split(f.size()); + + let text = [ + Text::raw("\nPress '"), + Text::styled("x", Style::default().fg(Color::LightGreen)), + Text::raw("' to add the selected report to the list of reports to print/export.\n"), + Text::raw("\nPress '"), + Text::styled("p", Style::default().fg(Color::Green)), + Text::raw("' to print/export the selected reports.\n"), + Text::raw("\nPress '"), + Text::styled("q", Style::default().fg(Color::Red)), + Text::raw("' to quit without printing.\n\n"), + ]; + Paragraph::new(text.iter()) + .block( + Block::default() + .borders(Borders::NONE) + .title("Instructions") + .title_style(Style::default().fg(Color::Blue).modifier(Modifier::BOLD)), + ) + .wrap(true) + .render(&mut f, chunks[1]); + + let draw_chunk = Layout::default() + .constraints([Constraint::Percentage(10), Constraint::Percentage(80),Constraint::Percentage(10),].as_ref()) + .direction(Direction::Horizontal) + .split(chunks[2]); + + SelectableList::default() + .block(Block::default().borders(Borders::ALL).title("Report List")) + .items(&app.tasks.items) + .select(Some(app.tasks.selected)) + .highlight_style(Style::default().fg(Color::Yellow).modifier(Modifier::BOLD)) + .highlight_symbol(">") + .render(&mut f, draw_chunk[1]); + + }) + } + + + pub enum Event { + Input(I), + Tick, + } + pub struct Events { + rx: mpsc::Receiver>, + input_handle: thread::JoinHandle<()>, + tick_handle: thread::JoinHandle<()>, + } + let events = Events::with_config(Config { + tick_rate: Duration::from_millis(250u64), + ..Config::default() + }); + #[derive(Debug, Clone, Copy)] + pub struct Config { + pub exit_key: Key, + pub tick_rate: Duration, + } + + impl Default for Config { + fn default() -> Config { + Config { + exit_key: Key::Char('q'), + tick_rate: Duration::from_millis(250), + } + } + } + + impl Events { + pub fn new() -> Events { + Events::with_config(Config::default()) + } + + pub fn with_config(config: Config) -> Events { + let (tx, rx) = mpsc::channel(); + let input_handle = { + let tx = tx.clone(); + thread::spawn(move || { + let stdin = io::stdin(); + for evt in stdin.keys() { + match evt { + Ok(key) => { + if let Err(_) = tx.send(Event::Input(key)) { + return; + } + if key == config.exit_key { + return; + } + } + Err(_) => {} + } + } + }) + }; + let tick_handle = { + let tx = tx.clone(); + thread::spawn(move || { + let tx = tx.clone(); + loop { + tx.send(Event::Tick).unwrap(); + thread::sleep(config.tick_rate); + } + }) + }; + Events { + rx, + input_handle, + tick_handle, + } + } + + pub fn next(&self) -> Result, mpsc::RecvError> { + self.rx.recv() + } + } + + let mut app = PrintWindow::new("Reports"); + loop { + draw(&mut terminal, &app)?; + match events.next()? { + Event::Input(key) => match key { + Key::Char(c) => { + app.on_key(c); + } + Key::Up => { + app.on_up(); + } + Key::Down => { + app.on_down(); + } + Key::Left => { + // app.on_left(); + } + Key::Right => { + // app.on_right(); + } + _ => {} + }, + _ => {} + } + if app.should_quit { + break; + } + } + + // Seem to need both of these for the native terminal to be available for println!()'s below + std::mem::drop(terminal); + std::thread::sleep(Duration::from_millis(10)); + + for report in app.to_print { + println!("Exporting: {}", TASKS[report]); + match report + 1 { + 1 => { + csv_export::_1_account_sums_to_csv( + &settings, + &raw_acct_map, + &account_map + ); + } + 2 => { + csv_export::_2_account_sums_nonzero_to_csv( + &account_map, + &settings, + &raw_acct_map + ); + } + 3 => { + csv_export::_3_account_sums_to_csv_with_orig_basis( + &settings, + &raw_acct_map, + &account_map + ); + } + 4 => { + csv_export::_4_transaction_mvmt_detail_to_csv( + &settings, + &action_records_map, + &raw_acct_map, + &account_map, + &transactions_map + )?; + } + 5 => { + csv_export::_5_transaction_mvmt_summaries_to_csv( + &settings, + &action_records_map, + &raw_acct_map, + &account_map, + &transactions_map + )?; + } + 6 => { + csv_export::_6_transaction_mvmt_detail_to_csv_w_orig( + &settings, + &action_records_map, + &raw_acct_map, + &account_map, + &transactions_map + )?; + } + 7 => { + txt_export::_1_account_lot_detail_to_txt( + &settings, + &raw_acct_map, + &account_map, + &transactions_map, + &action_records_map + )?; + } + 8 => { + txt_export::_2_account_lot_summary_to_txt( + &settings, + &raw_acct_map, + &account_map, + )?; + } + 9 => { + txt_export::_3_account_lot_summary_non_zero_to_txt( + &settings, + &raw_acct_map, + &account_map, + )?; + } + _ => {} + } + } + } + // use tests::test; // test::run_tests( // &transactions_map, diff --git a/src/setup.rs b/src/setup.rs index 6c868b1..c6291d1 100644 --- a/src/setup.rs +++ b/src/setup.rs @@ -70,6 +70,7 @@ pub (crate) fn run_setup(args: super::Cli) -> Result<(PathBuf, ImportProcessPara lk_basis_date_preserved: true, // TODO should_export: should_export, export_path: output_dir_path, + print_menu: args.flags.print_menu, }; Ok((input_file_path, settings))