diff --git a/Cargo.lock b/Cargo.lock index 372f131..92b15aa 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1399,7 +1399,7 @@ dependencies = [ "futures", "ironcalc", "ratatui", - "slice-cursor", + "slice-utils", "thiserror", "tui-prompts", "tui-textarea", @@ -1457,9 +1457,9 @@ dependencies = [ ] [[package]] -name = "slice-cursor" +name = "slice-utils" version = "0.1.0" -source = "git+https://dev.zaphar.net/zaphar/slice-cursor-rs.git#562a78eb3f06ac2a9729af7aa211a070f8ed9c39" +source = "git+https://dev.zaphar.net/zaphar/slice-cursor-rs.git#699df1c4c9d50e0c2ac2801723f8f2238b4f8c3b" [[package]] name = "smallvec" diff --git a/Cargo.toml b/Cargo.toml index ff3a622..d3b0e4e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -16,4 +16,4 @@ ratatui = "0.29.0" thiserror = "1.0.65" tui-textarea = "0.7.0" tui-prompts = "0.5.0" -slice-cursor = { git = "https://dev.zaphar.net/zaphar/slice-cursor-rs.git" } +slice-utils = { git = "https://dev.zaphar.net/zaphar/slice-cursor-rs.git", ref = "main" } diff --git a/src/ui/cmd.rs b/src/ui/cmd.rs index 980c705..cca92f9 100644 --- a/src/ui/cmd.rs +++ b/src/ui/cmd.rs @@ -1,5 +1,5 @@ //! Command mode command parsers. -use slice_cursor::{Cursor, Seekable, Span, SpanRange, StrCursor}; +use slice_utils::{Measured, Peekable, Seekable, Span, StrCursor}; #[derive(Debug, PartialEq, Eq)] pub enum Cmd<'a> { @@ -8,73 +8,94 @@ pub enum Cmd<'a> { InsertColumns(usize), Edit(&'a str), Help(Option<&'a str>), + Quit, } pub fn parse<'cmd, 'i: 'cmd>(input: &'i str) -> Result>, &'static str> { let cursor = StrCursor::new(input); // try consume write command. - if let Some(cmd) = try_consume_write(cursor.clone()) { + if let Some(cmd) = try_consume_write(cursor.clone())? { return Ok(Some(cmd)); } // try consume insert-row command. if let Some(cmd) = try_consume_insert_row(cursor.clone())? { return Ok(Some(cmd)); } - // try consume insert-col command. - if let Some(cmd) = try_consume_insert_column(cursor.clone()) { + //// try consume insert-col command. + if let Some(cmd) = try_consume_insert_column(cursor.clone())? { return Ok(Some(cmd)); } // try consume edit command. - if let Some(cmd) = try_consume_edit(cursor.clone()) { + if let Some(cmd) = try_consume_edit(cursor.clone())? { return Ok(Some(cmd)); } // try consume help command. - if let Some(cmd) = try_consume_help(cursor.clone()) { + if let Some(cmd) = try_consume_help(cursor.clone())? { + return Ok(Some(cmd)); + } + // try consume quit command. + if let Some(cmd) = try_consume_quit(cursor.clone())? { return Ok(Some(cmd)); } Ok(None) } -const WRITE: &'static str = "write"; - -pub fn try_consume_write<'cmd, 'i: 'cmd>(mut input: StrCursor<'i>) -> Option> { - let prefix_len = WRITE.len(); - let full_length = dbg!(input.span(..).len()); - let arg = if full_length >= prefix_len && input.span(..prefix_len) == WRITE { - input.seek(prefix_len); - // Should we check for whitespace? - input.span(prefix_len..) - } else if full_length >= 2 && input.span(..2) == "w " { - input.span(2..) - // Should we check for whitespace? - } else { - return None; - } - .trim(); - return Some(Cmd::Write(if arg.is_empty() { None } else { Some(arg) })); +fn compare<'i>(input: StrCursor<'i>, compare: &str) -> bool { + input.remaining() >= compare.len() && input.span(0..compare.len()) == compare } -const IR: &'static str = "ir"; -const INSERT_ROW: &'static str = "insert-row"; +fn is_ws<'r, 'i: 'r>(input: &'r mut StrCursor<'i>) -> bool { + match input.peek_next() { + Some(b) => { + if *b == (' ' as u8) || *b == ('\t' as u8) || *b == ('\n' as u8) || *b == ('\r' as u8) { + input.next(); + true + } else { + false + } + } + _ => false, + } +} -pub fn try_consume_insert_row<'cmd, 'i: 'cmd>( +fn try_consume_write<'cmd, 'i: 'cmd>( mut input: StrCursor<'i>, ) -> Result>, &'static str> { - let prefix_len = INSERT_ROW.len(); - let second_prefix_len = IR.len(); - let full_length = input.span(..).len(); - let arg = - if full_length >= prefix_len && input.span(..prefix_len) == INSERT_ROW { - input.seek(prefix_len); - // Should we check for whitespace? - input.span(prefix_len..) - } else if full_length >= second_prefix_len && input.span(..second_prefix_len) == IR { - input.span(second_prefix_len..) + const SHORT: &'static str = "w"; + const LONG: &'static str = "write"; + + if compare(input.clone(), LONG) { + input.seek(LONG.len()); + } else if compare(input.clone(), SHORT) { + input.seek(SHORT.len()); // Should we check for whitespace? } else { return Ok(None); } - .trim(); + if input.remaining() > 0 && !is_ws(&mut input) { + return Err("Invalid command: Did you mean to type `write `?"); + } + let arg = input.span(0..).trim(); + return Ok(Some(Cmd::Write(if arg.is_empty() { None } else { Some(arg) }))); +} + +fn try_consume_insert_row<'cmd, 'i: 'cmd>( + mut input: StrCursor<'i>, +) -> Result>, &'static str> { + const SHORT: &'static str = "ir"; + const LONG: &'static str = "insert-rows"; + + if compare(input.clone(), LONG) { + input.seek(LONG.len()); + } else if compare(input.clone(), SHORT) { + input.seek(SHORT.len()); + } else { + return Ok(None); + }; + if input.remaining() > 0 && !is_ws(&mut input) { + return Err("Invalid command: Did you mean to type `insert-rows `?"); + } + let arg = input.span(0..).trim(); return Ok(Some(Cmd::InsertRow(if arg.is_empty() { 1 } else { @@ -86,14 +107,89 @@ pub fn try_consume_insert_row<'cmd, 'i: 'cmd>( }))); } -pub fn try_consume_insert_column<'cmd, 'i: 'cmd>(mut input: StrCursor<'i>) -> Option> { - todo!("insert-column not yet implemented") +fn try_consume_insert_column<'cmd, 'i: 'cmd>( + mut input: StrCursor<'i>, +) -> Result>, &'static str> { + const SHORT: &'static str = "ic"; + const LONG: &'static str = "insert-cols"; + + if compare(input.clone(), LONG) { + input.seek(LONG.len()); + } else if compare(input.clone(), SHORT) { + input.seek(SHORT.len()); + } else { + return Ok(None); + }; + if input.remaining() > 0 && !is_ws(&mut input) { + return Err("Invalid command: Did you mean to type `insert-cols `?"); + } + let arg = input.span(0..).trim(); + return Ok(Some(Cmd::InsertColumns(if arg.is_empty() { + 1 + } else { + if let Ok(count) = arg.parse() { + count + } else { + return Err("You must pass in a non negative number for the row count"); + } + }))); } -pub fn try_consume_edit<'cmd, 'i: 'cmd>(mut input: StrCursor<'i>) -> Option> { - todo!("edit not yet implemented") +fn try_consume_edit<'cmd, 'i: 'cmd>(mut input: StrCursor<'i>) -> Result>, &'static str> { + const SHORT: &'static str = "e"; + const LONG: &'static str = "edit"; + + if compare(input.clone(), LONG) { + input.seek(LONG.len()); + } else if compare(input.clone(), SHORT) { + input.seek(SHORT.len()); + } else { + return Ok(None); + }; + if input.remaining() > 0 && !is_ws(&mut input) { + return Err("Invalid command: Did you mean to type `edit `?"); + } + let arg = input.span(0..).trim(); + return Ok(Some(Cmd::Edit(if arg.is_empty() { + return Err("You must pass in a path to edit"); + } else { + arg + }))); } -pub fn try_consume_help<'cmd, 'i: 'cmd>(mut input: StrCursor<'i>) -> Option> { - todo!("help not yet implemented") +fn try_consume_help<'cmd, 'i: 'cmd>(mut input: StrCursor<'i>) -> Result>, &'static str> { + const SHORT: &'static str = "?"; + const LONG: &'static str = "help"; + + if compare(input.clone(), LONG) { + input.seek(LONG.len()); + } else if compare(input.clone(), SHORT) { + input.seek(SHORT.len()); + // Should we check for whitespace? + } else { + return Ok(None); + } + if input.remaining() > 0 && !is_ws(&mut input) { + return Err("Invalid command: Did you mean to type `help `?"); + } + let arg = input.span(0..).trim(); + return Ok(Some(Cmd::Help(if arg.is_empty() { None } else { Some(arg) }))); +} + +fn try_consume_quit<'cmd, 'i: 'cmd>(mut input: StrCursor<'i>) -> Result>, &'static str> { + const SHORT: &'static str = "q"; + const LONG: &'static str = "quit"; + + if compare(input.clone(), LONG) { + input.seek(LONG.len()); + } else if compare(input.clone(), SHORT) { + input.seek(SHORT.len()); + // Should we check for whitespace? + } else { + return Ok(None); + } + if input.remaining() > 0 { + return Err("Invalid command: Quit does not take an argument"); + } + return Ok(Some(Cmd::Quit)); } diff --git a/src/ui/mod.rs b/src/ui/mod.rs index 7931ec9..c5395e1 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -7,7 +7,13 @@ use crate::book::Book; use anyhow::Result; 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 + 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, }; use tui_prompts::{State, Status, TextPrompt, TextState}; use tui_textarea::{CursorMove, TextArea}; @@ -16,6 +22,8 @@ mod cmd; #[cfg(test)] mod test; +use cmd::Cmd; + #[derive(Default, Debug, PartialEq)] pub enum Modality { #[default] @@ -79,14 +87,19 @@ impl<'ws> Workspace<'ws> { } pub fn load(path: &PathBuf, locale: &str, tz: &str) -> Result { - let book = if path.exists() { - Book::new_from_xlsx_with_locale(&path.to_string_lossy().to_string(), locale, tz)? - } else { - Book::default() - }; + let book = load_book(path, locale, tz)?; Ok(Workspace::new(book, path.clone())) } + pub fn load_into>(&mut self, path: P) -> Result<()> { + let path: PathBuf = path.into(); + // FIXME(zaphar): This should be managed better. + let book = load_book(&path, "en", "America/New_York")?; + self.book = book; + self.name = path; + Ok(()) + } + pub fn move_down(&mut self) -> Result<()> { let mut loc = self.book.location.clone(); let (row_count, _) = self.book.get_size()?; @@ -197,19 +210,47 @@ impl<'ws> Workspace<'ws> { Ok(None) } - fn handle_command(&mut self, cmd: String) -> Result { - if cmd.is_empty() { + fn handle_command(&mut self, cmd_text: String) -> Result { + if cmd_text.is_empty() { return Ok(true); } - match cmd.as_str() { - "w" | "write" => { - self.save_file()?; + match cmd::parse(&cmd_text) { + Ok(Some(Cmd::Edit(path))) => { + self.load_into(path)?; + Ok(true) + } + Ok(Some(Cmd::Help(_maybe_topic))) => { + // TODO(jeremy): Modal dialogs? + Ok(true) + } + Ok(Some(Cmd::Write(maybe_path))) => { + if let Some(path) = maybe_path { + self.save_to(path)?; + } else { + self.save_file()?; + } + Ok(true) + } + Ok(Some(Cmd::InsertColumns(count))) => { + self.book.insert_columns(self.book.location.col, count)?; + self.book.evaluate(); + Ok(true) }, - _ => { - // noop? + Ok(Some(Cmd::InsertRow(count))) => { + self.book.insert_rows(self.book.location.row, count)?; + self.book.evaluate(); + Ok(true) + }, + Ok(Some(Cmd::Quit)) => { + // TODO(zaphar): We probably need to do better than this + std::process::exit(0); + }, + Ok(None) => Ok(false), + Err(_msg) => { + // TODO(jeremy): Modal dialogs? + Ok(false) } } - Ok(false) } fn handle_navigation_input(&mut self, key: event::KeyEvent) -> Result> { @@ -343,12 +384,17 @@ impl<'ws> Workspace<'ws> { Ok(()) } - fn get_render_parts(&mut self, area: Rect) -> Vec<(Rect, Box)> { + fn save_to>(&self, path: S) -> Result<()> { + self.book.save_to_xlsx(path.into().as_str())?; + Ok(()) + } + + fn get_render_parts( + &mut self, + area: Rect, + ) -> Vec<(Rect, Box)> { use ratatui::widgets::StatefulWidget; - let mut cs = vec![ - Constraint::Fill(4), - Constraint::Fill(30), - ]; + let mut cs = vec![Constraint::Fill(4), Constraint::Fill(30)]; let Address { row, col } = self.book.location; let mut rs: Vec> = vec![ Box::new(|rect: Rect, buf: &mut Buffer, ws: &mut Self| ws.text_area.render(rect, buf)), @@ -374,22 +420,40 @@ impl<'ws> Workspace<'ws> { } if self.state.modality == Modality::Command { cs.push(Constraint::Max(1)); - rs.push(Box::new(|rect: Rect, buf: &mut Buffer, ws: &mut Self| StatefulWidget::render( + rs.push(Box::new(|rect: Rect, buf: &mut Buffer, ws: &mut Self| { + StatefulWidget::render( TextPrompt::from("Command"), rect, buf, - &mut ws.state.command_state) - )); + &mut ws.state.command_state, + ) + })); } - let rects: Vec = Vec::from(Layout::vertical(cs) - .vertical_margin(2) - .horizontal_margin(2) - .flex(Flex::Legacy) - .split(area.clone()).as_ref()); - rects.into_iter().zip(rs.into_iter()).map(|(rect, f)| (rect, f)).collect() + let rects: Vec = Vec::from( + Layout::vertical(cs) + .vertical_margin(2) + .horizontal_margin(2) + .flex(Flex::Legacy) + .split(area.clone()) + .as_ref(), + ); + rects + .into_iter() + .zip(rs.into_iter()) + .map(|(rect, f)| (rect, f)) + .collect() } } +fn load_book(path: &PathBuf, locale: &str, tz: &str) -> Result { + let book = if path.exists() { + Book::new_from_xlsx_with_locale(&path.to_string_lossy().to_string(), locale, tz)? + } else { + Book::default() + }; + Ok(book) +} + fn reset_text_area<'a>(content: String) -> TextArea<'a> { let mut text_area = TextArea::from(content.lines()); text_area.set_cursor_line_style(Style::default()); @@ -422,13 +486,12 @@ impl<'widget, 'ws: 'widget> Widget for &'widget mut Workspace<'ws> { )) .right_aligned(), ); - + for (rect, f) in self.get_render_parts(area.clone()) { - f(rect, buf, self); + f(rect, buf, self); } outer_block.render(area, buf); - } } diff --git a/src/ui/test.rs b/src/ui/test.rs index eafd0b2..9f55778 100644 --- a/src/ui/test.rs +++ b/src/ui/test.rs @@ -23,8 +23,8 @@ fn test_short_write_cmd() { } #[test] -fn test_insert_row_cmd() { - let input = "insert-row 1"; +fn test_insert_rows_cmd() { + let input = "insert-rows 1"; let result = parse(input); assert!(result.is_ok()); let output = result.unwrap(); @@ -34,7 +34,7 @@ fn test_insert_row_cmd() { } #[test] -fn test_insert_row_cmd_short() { +fn test_insert_rows_cmd_short() { let input = "ir 1"; let result = parse(input); assert!(result.is_ok()); @@ -43,3 +43,90 @@ fn test_insert_row_cmd_short() { let cmd = output.unwrap(); assert_eq!(cmd, Cmd::InsertRow(1)); } + +fn test_insert_cols_cmd() { + let input = "insert-cols 1"; + let result = parse(input); + assert!(result.is_ok()); + let output = result.unwrap(); + assert!(output.is_some()); + let cmd = output.unwrap(); + assert_eq!(cmd, Cmd::InsertColumns(1)); +} + +#[test] +fn test_insert_cols_cmd_short() { + let input = "ic 1"; + let result = parse(input); + assert!(result.is_ok()); + let output = result.unwrap(); + assert!(output.is_some()); + let cmd = output.unwrap(); + assert_eq!(cmd, Cmd::InsertColumns(1)); +} + +#[test] +fn test_edit_cmd() { + let input = "edit path.txt"; + let result = parse(input); + assert!(result.is_ok()); + let output = result.unwrap(); + assert!(output.is_some()); + let cmd = output.unwrap(); + assert_eq!(cmd, Cmd::Edit("path.txt")); +} + +#[test] +fn test_edit_cmd_short() { + let input = "e path.txt"; + let result = parse(input); + assert!(result.is_ok()); + let output = result.unwrap(); + assert!(output.is_some()); + let cmd = output.unwrap(); + assert_eq!(cmd, Cmd::Edit("path.txt")); +} + +#[test] +fn test_help_cmd() { + let input = "help topic"; + let result = parse(input); + assert!(result.is_ok()); + let output = result.unwrap(); + assert!(output.is_some()); + let cmd = output.unwrap(); + assert_eq!(cmd, Cmd::Help(Some("topic"))); +} + +#[test] +fn test_help_cmd_short() { + let input = "? topic"; + let result = parse(input); + assert!(result.is_ok()); + let output = result.unwrap(); + assert!(output.is_some()); + let cmd = output.unwrap(); + assert_eq!(cmd, Cmd::Help(Some("topic"))); +} + +#[test] +fn test_quit_cmd_short() { + let input = "q"; + let result = parse(input); + assert!(result.is_ok()); + let output = result.unwrap(); + assert!(output.is_some()); + let cmd = output.unwrap(); + assert_eq!(cmd, Cmd::Quit); +} + +#[test] +fn test_quit_cmd() { + let input = "quit"; + let result = parse(input); + assert!(result.is_ok()); + let output = result.unwrap(); + assert!(output.is_some()); + let cmd = output.unwrap(); + assert_eq!(cmd, Cmd::Quit); +}