diff --git a/src/book/mod.rs b/src/book/mod.rs index abf5d75..dfb91e6 100644 --- a/src/book/mod.rs +++ b/src/book/mod.rs @@ -35,10 +35,13 @@ impl Book { } } + /// Construct a new book from an xlsx file. pub fn new_from_xlsx(path: &str) -> Result { Ok(Self::new(load_from_xlsx(path, "en", "America/New_York")?)) } + /// Evaluate the spreadsheet calculating formulas and style changes. + /// This can be an expensive operation. pub fn evaluate(&mut self) { self.model.evaluate(); } @@ -59,6 +62,8 @@ impl Book { Ok(()) } + /// Get all the sheet identiers a `Vec<(String, u32)>` where the string + /// is the sheet name and the u32 is the sheet index. pub fn get_all_sheets_identifiers(&self) -> Vec<(String, u32)> { self.model .workbook @@ -68,7 +73,7 @@ impl Book { .collect() } - /// Get the currently set sheets name. + /// Get the current sheets name. pub fn get_sheet_name(&self) -> Result<&str> { Ok(&self.get_sheet()?.name) } @@ -78,23 +83,24 @@ impl Book { Ok(&self.get_sheet()?.sheet_data) } - pub fn move_to(&mut self, loc: Address) -> Result<()> { + /// Move to a specific sheel location in the current sheet + pub fn move_to(&mut self, Address { row, col }: &Address) -> Result<()> { // FIXME(zaphar): Check that this is safe first. - self.location.row = loc.row; - self.location.col = loc.col; + self.location.row = *row; + self.location.col = *col; Ok(()) } /// Get a cells formatted content. pub fn get_current_cell_rendered(&self) -> Result { - Ok(self.get_cell_addr_rendered(self.location.row, self.location.col)?) + Ok(self.get_cell_addr_rendered(&self.location)?) } - // TODO(zaphar): Use Address here too - pub fn get_cell_addr_rendered(&self, row: usize, col: usize) -> Result { + /// Get a cells rendered content for display. + pub fn get_cell_addr_rendered(&self, Address { row, col }: &Address) -> Result { Ok(self .model - .get_formatted_cell_value(self.current_sheet, row as i32, col as i32) + .get_formatted_cell_value(self.current_sheet, *row as i32, *col as i32) .map_err(|s| anyhow!("Unable to format cell {}", s))?) } @@ -131,12 +137,13 @@ impl Book { Ok(()) } + /// Insert `count` rows at a `row_idx`. pub fn insert_rows(&mut self, row_idx: usize, count: usize) -> Result<()> { self.model .insert_rows(self.current_sheet, row_idx as i32, count as i32) .map_err(|e| anyhow!("Unable to insert row(s): {}", e))?; if self.location.row >= row_idx { - self.move_to(Address { + self.move_to(&Address { row: self.location.row + count, col: self.location.col, })?; @@ -144,12 +151,13 @@ impl Book { Ok(()) } + /// Insert `count` columns at a `col_idx`. pub fn insert_columns(&mut self, col_idx: usize, count: usize) -> Result<()> { self.model .insert_columns(self.current_sheet, col_idx as i32, count as i32) .map_err(|e| anyhow!("Unable to insert column(s): {}", e))?; if self.location.col >= col_idx { - self.move_to(Address { + self.move_to(&Address { row: self.location.row, col: self.location.col + count, })?; @@ -211,6 +219,7 @@ impl Book { false } + /// Get the current `Worksheet`. pub(crate) fn get_sheet(&self) -> Result<&Worksheet> { Ok(self .model diff --git a/src/book/test.rs b/src/book/test.rs index a750502..0055b8e 100644 --- a/src/book/test.rs +++ b/src/book/test.rs @@ -69,7 +69,7 @@ fn test_book_insert_rows() { let mut book = Book::default(); book.update_entry(&Address { row: 2, col: 2 }, "1") .expect("failed to edit cell"); - book.move_to(Address { row: 2, col: 2 }).expect("Failed to move to location"); + book.move_to(&Address { row: 2, col: 2 }).expect("Failed to move to location"); assert_eq!((2, 2), book.get_size().expect("Failed to get size")); book.insert_rows(1, 5).expect("Failed to insert rows"); assert_eq!((7, 2), book.get_size().expect("Failed to get size")); @@ -82,7 +82,7 @@ fn test_book_insert_columns() { let mut book = Book::default(); book.update_entry(&Address { row: 2, col: 2 }, "1") .expect("failed to edit cell"); - book.move_to(Address { row: 2, col: 2 }).expect("Failed to move to location"); + book.move_to(&Address { row: 2, col: 2 }).expect("Failed to move to location"); assert_eq!((2, 2), book.get_size().expect("Failed to get size")); book.insert_columns(1, 5).expect("Failed to insert rows"); assert_eq!((2, 7), book.get_size().expect("Failed to get size")); diff --git a/src/main.rs b/src/main.rs index 1fe5b98..82710c3 100644 --- a/src/main.rs +++ b/src/main.rs @@ -22,7 +22,7 @@ fn run(terminal: &mut ratatui::DefaultTerminal, args: Args) -> anyhow::Result { Write(Option<&'a str>), @@ -11,6 +12,7 @@ pub enum Cmd<'a> { Quit, } +/// Parse command text into a `Cmd`. pub fn parse<'cmd, 'i: 'cmd>(input: &'i str) -> Result>, &'static str> { let cursor = StrCursor::new(input); // try consume write command. diff --git a/src/ui/mod.rs b/src/ui/mod.rs index f6141b4..f4bb8a3 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -1,24 +1,21 @@ //! Ui rendering logic - use std::{path::PathBuf, process::ExitCode}; use crate::book::Book; use anyhow::Result; -use crossterm::event::{self, Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers}; +use crossterm::event::{self, Event, KeyCode, KeyEventKind, KeyModifiers}; use ratatui::{ self, buffer::Buffer, layout::{Constraint, Flex, Layout, Rect}, - style::{Color, Modifier, Style, Stylize}, - text::{Line, Text}, - widgets::{Block, Cell, Paragraph, Row, Table, TableState, Widget}, - Frame, + style::{Modifier, Style}, + widgets::{Block, Table, TableState, Widget}, }; -use tui_popup::Popup; use tui_prompts::{State, Status, TextPrompt, TextState}; use tui_textarea::{CursorMove, TextArea}; +pub mod render; mod cmd; #[cfg(test)] mod test; @@ -31,7 +28,6 @@ pub enum Modality { Navigate, CellEdit, Command, - // TODO(zaphar): Command Mode? Dialog, } @@ -40,6 +36,8 @@ pub struct AppState<'ws> { pub modality_stack: Vec, pub table_state: TableState, pub command_state: TextState<'ws>, + dirty: bool, + popup: Vec } impl<'ws> Default for AppState<'ws> { @@ -47,7 +45,9 @@ impl<'ws> Default for AppState<'ws> { AppState { modality_stack: vec![Modality::default()], table_state: Default::default(), - command_state: Default::default() + command_state: Default::default(), + dirty: Default::default(), + popup: Default::default(), } } } @@ -83,37 +83,34 @@ impl Default for Address { } } -// Interaction Modalities -// * Navigate -// * Edit +/// A workspace defining our UI state. pub struct Workspace<'ws> { name: PathBuf, book: Book, state: AppState<'ws>, text_area: TextArea<'ws>, - dirty: bool, - popup: Vec } impl<'ws> Workspace<'ws> { + /// Constructs a new Workspace from an `Book` with a path for the name. pub fn new(book: Book, name: PathBuf) -> Self { let mut ws = Self { book, name, state: AppState::default(), text_area: reset_text_area("".to_owned()), - dirty: false, - popup: Vec::new(), }; ws.handle_movement_change(); ws } + /// Loads a workspace from a path. pub fn load(path: &PathBuf, locale: &str, tz: &str) -> Result { let book = load_book(path, locale, tz)?; Ok(Workspace::new(book, path.clone())) } + /// Loads a new `Book` into a `Workspace` from a path. pub fn load_into>(&mut self, path: P) -> Result<()> { let path: PathBuf = path.into(); // FIXME(zaphar): This should be managed better. @@ -123,45 +120,52 @@ impl<'ws> Workspace<'ws> { Ok(()) } + /// Move a row down in the current sheet. pub fn move_down(&mut self) -> Result<()> { let mut loc = self.book.location.clone(); let (row_count, _) = self.book.get_size()?; if loc.row < row_count { loc.row += 1; - self.book.move_to(loc)?; + self.book.move_to(&loc)?; } Ok(()) } + /// Move a row up in the current sheet. pub fn move_up(&mut self) -> Result<()> { let mut loc = self.book.location.clone(); if loc.row > 1 { loc.row -= 1; - self.book.move_to(loc)?; + self.book.move_to(&loc)?; } Ok(()) } + /// Move a column to the left in the current sheet. pub fn move_left(&mut self) -> Result<()> { let mut loc = self.book.location.clone(); if loc.col > 1 { loc.col -= 1; - self.book.move_to(loc)?; + self.book.move_to(&loc)?; } Ok(()) } + /// Move a column to the left in the current sheet. pub fn move_right(&mut self) -> Result<()> { let mut loc = self.book.location.clone(); let (_, col_count) = self.book.get_size()?; if loc.col < col_count { loc.col += 1; - self.book.move_to(loc)?; + self.book.move_to(&loc)?; } Ok(()) } + /// Handle input in our ui loop. pub fn handle_input(&mut self) -> Result> { + // TODO(jwall): We probably want to separate this out into + // a pure function so we can script various testing scenarios. if let Event::Key(key) = event::read()? { let result = match self.state.modality() { Modality::Navigate => self.handle_navigation_input(key)?, @@ -242,7 +246,7 @@ impl<'ws> Workspace<'ws> { // * Copy // * Paste if self.text_area.input(key) { - self.dirty = true; + self.state.dirty = true; } Ok(None) } @@ -321,7 +325,7 @@ impl<'ws> Workspace<'ws> { let mut loc = self.book.location.clone(); if loc.row < row as usize { loc.row = row as usize; - self.book.move_to(loc)?; + self.book.move_to(&loc)?; } self.handle_movement_change(); } @@ -359,11 +363,6 @@ impl<'ws> Workspace<'ws> { } } } - // TODO(jeremy): Handle some useful navigation operations. - // * Copy Cell reference - // * Copy Cell Range reference - // * Extend Cell {down,up} - // * Goto location. (Command modality?) return Ok(None); } @@ -379,7 +378,7 @@ impl<'ws> Workspace<'ws> { } fn enter_dialog_mode(&mut self, msg: Vec) { - self.popup = msg; + self.state.popup = msg; self.state.modality_stack.push(Modality::Dialog); } @@ -411,10 +410,10 @@ impl<'ws> Workspace<'ws> { self.text_area.set_cursor_line_style(Style::default()); self.text_area.set_cursor_style(Style::default()); let contents = self.text_area.lines().join("\n"); - if self.dirty { + if self.state.dirty { self.book.edit_current_cell(contents)?; self.book.evaluate(); - self.dirty = false; + self.state.dirty = false; } self.enter_navigation_mode(); Ok(()) @@ -504,105 +503,3 @@ fn reset_text_area<'a>(content: String) -> TextArea<'a> { text_area.set_block(Block::bordered()); text_area } - -impl<'widget, 'ws: 'widget> Widget for &'widget mut Workspace<'ws> { - fn render(self, area: Rect, buf: &mut ratatui::prelude::Buffer) - where - Self: Sized, - { - let outer_block = Block::bordered() - .title(Line::from( - self.name - .file_name() - .map(|p| p.to_string_lossy().to_string()) - .unwrap_or_else(|| String::from("Unknown")), - )) - .title_bottom(match self.state.modality() { - Modality::Navigate => "navigate", - Modality::CellEdit => "edit", - Modality::Command => "command", - Modality::Dialog => "", - }) - .title_bottom( - Line::from(format!( - "{},{}", - self.book.location.row, self.book.location.col - )) - .right_aligned(), - ); - - for (rect, f) in self.get_render_parts(area.clone()) { - f(rect, buf, self); - } - - outer_block.render(area, buf); - - if self.state.modality() == &Modality::Dialog { - let lines = Text::from_iter(self.popup.iter().cloned()); - let popup = Popup::new(lines); - popup.render(area, buf); - } - } -} - -const COLNAMES: [&'static str; 26] = [ - "A", "B", "C", "D", "E", "F", "G", "H", "I", "J", "K", "L", "M", "N", "O", "P", "Q", "R", "S", - "T", "U", "V", "W", "X", "Y", "Z", -]; - -impl<'t, 'book: 't> TryFrom<&'book Book> for Table<'t> { - fn try_from(value: &'book Book) -> std::result::Result { - // TODO(zaphar): This is apparently expensive. Maybe we can cache it somehow? - // We should do the correct thing here if this fails - let (row_count, col_count) = value.get_size()?; - let rows: Vec = (1..=row_count) - .into_iter() - .map(|ri| { - let mut cells = vec![Cell::new(Text::from(ri.to_string()))]; - cells.extend((1..=col_count).into_iter().map(|ci| { - // TODO(zaphar): Is this safe? - let content = value.get_cell_addr_rendered(ri, ci).unwrap(); - let cell = Cell::new(Text::raw(content)); - match (value.location.row == ri, value.location.col == ci) { - (true, true) => cell.fg(Color::White).underlined(), - _ => cell - .bg(if ri % 2 == 0 { - Color::Rgb(57, 61, 71) - } else { - Color::Rgb(165, 169, 160) - }) - .fg(if ri % 2 == 0 { - Color::White - } else { - Color::Rgb(31, 32, 34) - }), - } - .bold() - })); - Row::new(cells) - }) - .collect(); - let mut constraints: Vec = Vec::new(); - constraints.push(Constraint::Max(5)); - for _ in 0..col_count { - constraints.push(Constraint::Min(5)); - } - let mut header = Vec::with_capacity(col_count as usize); - header.push(Cell::new("")); - header.extend((0..(col_count as usize)).map(|i| { - let count = (i / 26) + 1; - Cell::new(COLNAMES[i % 26].repeat(count)) - })); - Ok(Table::new(rows, constraints) - .block(Block::bordered()) - .header(Row::new(header).underlined()) - .column_spacing(1) - .flex(Flex::SpaceAround)) - } - - type Error = anyhow::Error; -} - -pub fn draw(frame: &mut Frame, ws: &mut Workspace) { - frame.render_widget(ws, frame.area()); -} diff --git a/src/ui/render.rs b/src/ui/render.rs new file mode 100644 index 0000000..81039ba --- /dev/null +++ b/src/ui/render.rs @@ -0,0 +1,113 @@ +use ratatui::{ + self, + layout::{Constraint, Flex, Rect}, + style::{Color, Stylize}, + text::{Line, Text}, + widgets::{Block, Cell, Row, Table, Widget}, + Frame, +}; +use tui_popup::Popup; + +use super::*; + +impl<'widget, 'ws: 'widget> Widget for &'widget mut Workspace<'ws> { + fn render(self, area: Rect, buf: &mut ratatui::prelude::Buffer) + where + Self: Sized, + { + let outer_block = Block::bordered() + .title(Line::from( + self.name + .file_name() + .map(|p| p.to_string_lossy().to_string()) + .unwrap_or_else(|| String::from("Unknown")), + )) + .title_bottom(match self.state.modality() { + Modality::Navigate => "navigate", + Modality::CellEdit => "edit", + Modality::Command => "command", + Modality::Dialog => "", + }) + .title_bottom( + Line::from(format!( + "{},{}", + self.book.location.row, self.book.location.col + )) + .right_aligned(), + ); + + for (rect, f) in self.get_render_parts(area.clone()) { + f(rect, buf, self); + } + + outer_block.render(area, buf); + + if self.state.modality() == &Modality::Dialog { + let lines = Text::from_iter(self.state.popup.iter().cloned()); + let popup = Popup::new(lines); + popup.render(area, buf); + } + } +} + +const COLNAMES: [&'static str; 26] = [ + "A", "B", "C", "D", "E", "F", "G", "H", "I", "J", "K", "L", "M", "N", "O", "P", "Q", "R", "S", + "T", "U", "V", "W", "X", "Y", "Z", +]; + +impl<'t, 'book: 't> TryFrom<&'book Book> for Table<'t> { + fn try_from(value: &'book Book) -> std::result::Result { + // TODO(zaphar): This is apparently expensive. Maybe we can cache it somehow? + // We should do the correct thing here if this fails + let (row_count, col_count) = value.get_size()?; + let rows: Vec = (1..=row_count) + .into_iter() + .map(|ri| { + let mut cells = vec![Cell::new(Text::from(ri.to_string()))]; + cells.extend((1..=col_count).into_iter().map(|ci| { + // TODO(zaphar): Is this safe? + let content = value.get_cell_addr_rendered(&Address{ row: ri, col: ci }).unwrap(); + let cell = Cell::new(Text::raw(content)); + match (value.location.row == ri, value.location.col == ci) { + (true, true) => cell.fg(Color::White).underlined(), + _ => cell + .bg(if ri % 2 == 0 { + Color::Rgb(57, 61, 71) + } else { + Color::Rgb(165, 169, 160) + }) + .fg(if ri % 2 == 0 { + Color::White + } else { + Color::Rgb(31, 32, 34) + }), + } + .bold() + })); + Row::new(cells) + }) + .collect(); + let mut constraints: Vec = Vec::new(); + constraints.push(Constraint::Max(5)); + for _ in 0..col_count { + constraints.push(Constraint::Min(5)); + } + let mut header = Vec::with_capacity(col_count as usize); + header.push(Cell::new("")); + header.extend((0..(col_count as usize)).map(|i| { + let count = (i / 26) + 1; + Cell::new(COLNAMES[i % 26].repeat(count)) + })); + Ok(Table::new(rows, constraints) + .block(Block::bordered()) + .header(Row::new(header).underlined()) + .column_spacing(1) + .flex(Flex::SpaceAround)) + } + + type Error = anyhow::Error; +} + +pub fn draw(frame: &mut Frame, ws: &mut Workspace) { + frame.render_widget(ws, frame.area()); +} diff --git a/src/ui/test.rs b/src/ui/test.rs index 9f55778..4a65f07 100644 --- a/src/ui/test.rs +++ b/src/ui/test.rs @@ -44,6 +44,7 @@ fn test_insert_rows_cmd_short() { assert_eq!(cmd, Cmd::InsertRow(1)); } +#[test] fn test_insert_cols_cmd() { let input = "insert-cols 1"; let result = parse(input);