feat: Some command implementations

completes #3
This commit is contained in:
Jeremy Wall 2024-11-21 20:55:55 -05:00
parent e91c149619
commit a8f436894c
5 changed files with 327 additions and 81 deletions

6
Cargo.lock generated
View File

@ -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"

View File

@ -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" }

View File

@ -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<Option<Cmd<'cmd>>, &'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<Cmd<'cmd>> {
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<Option<Cmd<'cmd>>, &'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 <arg>`?");
}
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<Option<Cmd<'cmd>>, &'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 <arg>`?");
}
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<Cmd<'cmd>> {
todo!("insert-column not yet implemented")
fn try_consume_insert_column<'cmd, 'i: 'cmd>(
mut input: StrCursor<'i>,
) -> Result<Option<Cmd<'cmd>>, &'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 <arg>`?");
}
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<Cmd<'cmd>> {
todo!("edit not yet implemented")
fn try_consume_edit<'cmd, 'i: 'cmd>(mut input: StrCursor<'i>) -> Result<Option<Cmd<'cmd>>, &'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 <arg>`?");
}
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<Cmd<'cmd>> {
todo!("help not yet implemented")
fn try_consume_help<'cmd, 'i: 'cmd>(mut input: StrCursor<'i>) -> Result<Option<Cmd<'cmd>>, &'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 <arg>`?");
}
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<Option<Cmd<'cmd>>, &'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));
}

View File

@ -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<Self> {
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<P: Into<PathBuf>>(&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<bool> {
if cmd.is_empty() {
fn handle_command(&mut self, cmd_text: String) -> Result<bool> {
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<Option<ExitCode>> {
@ -343,12 +384,17 @@ impl<'ws> Workspace<'ws> {
Ok(())
}
fn get_render_parts(&mut self, area: Rect) -> Vec<(Rect, Box<dyn Fn(Rect, &mut Buffer, &mut Self)>)> {
fn save_to<S: Into<String>>(&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<dyn Fn(Rect, &mut Buffer, &mut Self)>)> {
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<Box<dyn Fn(Rect, &mut Buffer, &mut Self)>> = 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<Rect> = 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<Rect> = 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<Book, anyhow::Error> {
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);
}
}

View File

@ -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);
}