Compare commits

..

11 Commits

12 changed files with 288 additions and 42 deletions

View File

@ -35,6 +35,13 @@ Options:
sheetui path/to/file.xlsx # edit/view a spreadsheet
```
### Supported files
Currently we only support the [ironcalc](https://docs.ironcalc.com/) xlsx
features for spreadsheet. CSV import and expor are planned.
### Screenshot
<img src="./assets/screenshot.png" />
## Reference

View File

@ -20,6 +20,13 @@ Options:
-V, --version Print version
```
## Supported formats
Currently we only support the [ironcalc](https://docs.ironcalc.com/) xlsx
features for spreadsheet. I plan to handle csv import and export at some point.
I also might support other export formats as well but for the moment just csv
and it's variants such as tsv are in the roadmap.
## User Interface
The sheetui user interface is loosely inspired by vim. It is a modal interface
@ -61,8 +68,12 @@ will clear the numeric prefix if you want to cancel it.
**Other Keybindings**
* `Ctrl-r` will enter range selection mode
* `Ctrl-r` will enter range selection mode.
* `v` will enter range selection mode with the start of the range already selected.
* `Ctrl-s` will save the sheet.
* `Ctrl-c`, `y` Copy the cell or range contents.
* `Ctrl-v`, `p` Paste into the sheet.
* `Ctrl-Shift-C` Copy the cell or range formatted content.
* `q` will exit the application.
* `:` will enter CommandMode.
@ -129,6 +140,8 @@ select mode from CellEdit mode with `CTRL-r`.
* `h`, `j`, `k`, `l` will navigate around the sheet.
* `Ctrl-n`, `Ctrl-p` will navigate between sheets.
* `Ctrl-c`, `y` Copy the cell or range contents.
* `Ctrl-Shift-C`, 'Y' Copy the cell or range formatted content.
* `The spacebar will select the start and end of the range respectively.
* `d` will delete the contents of the range leaving any style untouched
* `D` will delete the contents of the range including any style

Binary file not shown.

Binary file not shown.

View File

@ -96,13 +96,29 @@ impl Book {
Ok(&self.get_sheet()?.sheet_data)
}
/// Move to a specific sheel location in the current sheet
/// Move to a specific sheet 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 = *row;
self.location.col = *col;
Ok(())
}
/// Extend a cell to the rest of the range.
pub fn extend_to(&mut self, from: &Address, to: &Address) -> Result<()> {
for ri in from.row..=to.row {
for ci in from.col..=to.col {
if ri == from.row && ci == from.col {
continue;
}
let contents = self.model.extend_to(self.current_sheet, from.row as i32, from.col as i32, ri as i32, ci as i32).map_err(|e| anyhow!(e))?;
self.model.set_user_input(self.current_sheet, ri as i32, ci as i32, contents)
.map_err(|e| anyhow!(e))?;
}
}
self.evaluate();
Ok(())
}
pub fn clear_current_cell(&mut self) -> Result<()> {
self.clear_cell_contents(self.current_sheet as u32, self.location.clone())
@ -158,6 +174,15 @@ impl Book {
.get_formatted_cell_value(self.current_sheet, *row as i32, *col as i32)
.map_err(|s| anyhow!("Unable to format cell {}", s))?)
}
/// Get a cells actual content unformatted as a string.
pub fn get_cell_addr_contents(&self, Address { row, col }: &Address) -> Result<String> {
Ok(self
.model
.get_cell_content(self.current_sheet, *row as i32, *col as i32)
.map_err(|s| anyhow!("Unable to format cell {}", s))?)
}
/// Get a cells actual content as a string.
pub fn get_current_cell_contents(&self) -> Result<String> {
@ -174,13 +199,13 @@ impl Book {
/// Update the current cell in a book.
/// This update won't be reflected until you call `Book::evaluate`.
pub fn edit_current_cell<S: Into<String>>(&mut self, value: S) -> Result<()> {
self.update_entry(&self.location.clone(), value)?;
self.update_cell(&self.location.clone(), value)?;
Ok(())
}
/// Update an entry in the current sheet for a book.
/// This update won't be reflected until you call `Book::evaluate`.
pub fn update_entry<S: Into<String>>(&mut self, location: &Address, value: S) -> Result<()> {
pub fn update_cell<S: Into<String>>(&mut self, location: &Address, value: S) -> Result<()> {
self.model
.set_user_input(
self.current_sheet,
@ -349,7 +374,7 @@ impl Default for Book {
fn default() -> Self {
let mut book =
Book::new(Model::new_empty("default_name", "en", "America/New_York").unwrap());
book.update_entry(&Address { row: 1, col: 1 }, "").unwrap();
book.update_cell(&Address { row: 1, col: 1 }, "").unwrap();
book
}
}

View File

@ -36,7 +36,7 @@ fn test_book_default() {
#[test]
fn test_book_insert_cell_new_row() {
let mut book = Book::default();
book.update_entry(&Address { row: 2, col: 1 }, "1")
book.update_cell(&Address { row: 2, col: 1 }, "1")
.expect("failed to edit cell");
book.evaluate();
let WorksheetDimension {
@ -52,7 +52,7 @@ fn test_book_insert_cell_new_row() {
#[test]
fn test_book_insert_cell_new_column() {
let mut book = Book::default();
book.update_entry(&Address { row: 1, col: 2 }, "1")
book.update_cell(&Address { row: 1, col: 2 }, "1")
.expect("failed to edit cell");
let WorksheetDimension {
min_row,
@ -67,7 +67,7 @@ fn test_book_insert_cell_new_column() {
#[test]
fn test_book_insert_rows() {
let mut book = Book::default();
book.update_entry(&Address { row: 2, col: 2 }, "1")
book.update_cell(&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");
@ -85,7 +85,7 @@ fn test_book_insert_rows() {
#[test]
fn test_book_insert_columns() {
let mut book = Book::default();
book.update_entry(&Address { row: 2, col: 2 }, "1")
book.update_cell(&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");
@ -103,7 +103,7 @@ fn test_book_insert_columns() {
#[test]
fn test_book_col_size() {
let mut book = Book::default();
book.update_entry(&Address { row: 2, col: 2 }, "1")
book.update_cell(&Address { row: 2, col: 2 }, "1")
.expect("failed to edit cell");
book.set_col_size(1, 20).expect("Failed to set column size");
assert_eq!(20, book.get_col_size(1).expect("Failed to get column size"));

View File

@ -5,7 +5,7 @@ use slice_utils::{Measured, Peekable, Seekable, Span, StrCursor};
#[derive(Debug, PartialEq, Eq)]
pub enum Cmd<'a> {
Write(Option<&'a str>),
InsertRow(usize),
InsertRows(usize),
InsertColumns(usize),
RenameSheet(Option<usize>, &'a str),
NewSheet(Option<&'a str>),
@ -155,7 +155,7 @@ fn try_consume_insert_row<'cmd, 'i: 'cmd>(
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() {
return Ok(Some(Cmd::InsertRows(if arg.is_empty() {
1
} else {
if let Ok(count) = arg.parse() {

View File

@ -66,15 +66,23 @@ impl RangeSelection {
}
}
#[derive(Debug)]
pub enum ClipboardContents {
Cell(String),
Range(Vec<Vec<String>>),
}
#[derive(Debug)]
pub struct AppState<'ws> {
pub modality_stack: Vec<Modality>,
pub viewport_state: ViewportState,
pub command_state: TextState<'ws>,
pub numeric_prefix: Vec<char>,
pub char_queue: Vec<char>,
pub range_select: RangeSelection,
dirty: bool,
popup: Vec<String>,
clipboard: Option<ClipboardContents>,
}
impl<'ws> Default for AppState<'ws> {
@ -84,9 +92,11 @@ impl<'ws> Default for AppState<'ws> {
viewport_state: Default::default(),
command_state: Default::default(),
numeric_prefix: Default::default(),
char_queue: Default::default(),
range_select: Default::default(),
dirty: Default::default(),
popup: Default::default(),
clipboard: Default::default(),
}
}
}
@ -232,6 +242,12 @@ impl<'ws> Workspace<'ws> {
Ok(())
}
/// Move to the top row without changing columns
pub fn move_to_top(&mut self) -> Result<()> {
self.book.move_to(&Address { row: 1, col: self.book.location.col })?;
Ok(())
}
/// Move a row up in the current sheet.
pub fn move_up(&mut self) -> Result<()> {
let mut loc = self.book.location.clone();
@ -301,6 +317,7 @@ impl<'ws> Workspace<'ws> {
"Edit Mode:".to_string(),
"* ENTER/RETURN: Exit edit mode and save changes".to_string(),
"* Ctrl-r: Enter Range Selection mode".to_string(),
"* v: Enter Range Selection mode with the start of the range already selected".to_string(),
"* ESC: Exit edit mode and discard changes".to_string(),
"Otherwise edit as normal".to_string(),
],
@ -364,7 +381,7 @@ impl<'ws> Workspace<'ws> {
return Ok(None);
}
KeyCode::Char('r') if key.modifiers == KeyModifiers::CONTROL => {
self.enter_range_select_mode();
self.enter_range_select_mode(false);
return Ok(None);
}
KeyCode::Char('p') if key.modifiers == KeyModifiers::CONTROL => {
@ -417,7 +434,7 @@ impl<'ws> Workspace<'ws> {
self.book.evaluate();
Ok(None)
}
Ok(Some(Cmd::InsertRow(count))) => {
Ok(Some(Cmd::InsertRows(count))) => {
self.book.insert_rows(self.book.location.row, count)?;
self.book.evaluate();
Ok(None)
@ -443,7 +460,6 @@ impl<'ws> Workspace<'ws> {
Ok(None)
}
Ok(Some(Cmd::Quit)) => {
// TODO(zaphar): We probably need to do better than this
Ok(Some(ExitCode::SUCCESS))
}
Ok(None) => {
@ -533,12 +549,7 @@ impl<'ws> Workspace<'ws> {
self.maybe_update_range_end();
}
KeyCode::Char(' ') | KeyCode::Enter => {
if self.state.range_select.start.is_none() {
self.state.range_select.start = Some(self.book.location.clone());
} else {
self.state.range_select.end = Some(self.book.location.clone());
self.exit_range_select_mode()?;
}
self.update_range_selection()?;
}
KeyCode::Char('n') if key.modifiers == KeyModifiers::CONTROL => {
self.state.range_select.reset_range_selection();
@ -556,6 +567,25 @@ impl<'ws> Workspace<'ws> {
})?;
self.state.range_select.sheet = Some(self.book.current_sheet);
}
KeyCode::Char('C')
if key
.modifiers
.contains(KeyModifiers::CONTROL | KeyModifiers::SHIFT) =>
{
// TODO(zaphar): Share the algorithm below between both copies
self.copy_range(true)?;
}
KeyCode::Char('Y') => self.copy_range(true)?,
KeyCode::Char('c') if key.modifiers == KeyModifiers::CONTROL => {
self.copy_range(false)?;
}
KeyCode::Char('y') => self.copy_range(false)?,
KeyCode::Char('x') => {
if let (Some(from), Some(to)) = (self.state.range_select.start.as_ref(), self.state.range_select.end.as_ref()) {
self.book.extend_to(from, to)?;
}
self.exit_range_select_mode()?;
}
_ => {
// moop
}
@ -564,6 +594,59 @@ impl<'ws> Workspace<'ws> {
Ok(None)
}
fn copy_range(&mut self, formatted: bool) -> Result<(), anyhow::Error> {
self.update_range_selection()?;
match &self.state.range_select.get_range() {
Some((
Address {
row: row_start,
col: col_start,
},
Address {
row: row_end,
col: col_end,
},
)) => {
let mut rows = Vec::new();
for ri in (*row_start)..=(*row_end) {
let mut cols = Vec::new();
for ci in (*col_start)..=(*col_end) {
cols.push(if formatted {
self.book
.get_cell_addr_rendered(&Address { row: ri, col: ci })?
} else {
self.book
.get_cell_addr_contents(&Address { row: ri, col: ci })?
});
}
rows.push(cols);
}
self.state.clipboard = Some(ClipboardContents::Range(rows));
}
None => {
self.state.clipboard = Some(ClipboardContents::Cell(if formatted {
self.book
.get_current_cell_rendered()?
} else {
self.book
.get_current_cell_contents()?
}));
}
}
self.exit_range_select_mode()?;
Ok(())
}
fn update_range_selection(&mut self) -> Result<(), anyhow::Error> {
Ok(if self.state.range_select.start.is_none() {
self.state.range_select.start = Some(self.book.location.clone());
self.state.range_select.end = Some(self.book.location.clone());
} else {
self.state.range_select.end = Some(self.book.location.clone());
self.exit_range_select_mode()?;
})
}
fn maybe_update_range_end(&mut self) {
if self.state.range_select.start.is_some() {
self.state.range_select.end = Some(self.book.location.clone());
@ -588,8 +671,46 @@ impl<'ws> Workspace<'ws> {
KeyCode::Char('s') if key.modifiers == KeyModifiers::CONTROL => {
self.save_file()?;
}
KeyCode::Char('s') if key.modifiers != KeyModifiers::CONTROL => {
self.book.clear_current_cell()?;
self.text_area = reset_text_area(String::new());
self.enter_edit_mode();
}
KeyCode::Char('r') if key.modifiers == KeyModifiers::CONTROL => {
self.enter_range_select_mode();
self.enter_range_select_mode(false);
}
KeyCode::Char('c') if key.modifiers == KeyModifiers::CONTROL => {
self.state.clipboard = Some(ClipboardContents::Cell(
self.book.get_current_cell_contents()?,
));
}
KeyCode::Char('y') => {
self.state.clipboard = Some(ClipboardContents::Cell(
self.book.get_current_cell_contents()?,
));
}
KeyCode::Char('Y') => {
self.state.clipboard = Some(ClipboardContents::Cell(
self.book.get_current_cell_rendered()?,
));
}
KeyCode::Char('C')
if key
.modifiers
.contains(KeyModifiers::CONTROL | KeyModifiers::SHIFT) =>
{
self.state.clipboard = Some(ClipboardContents::Cell(
self.book.get_current_cell_rendered()?,
));
}
KeyCode::Char('v') if key.modifiers != KeyModifiers::CONTROL => {
self.enter_range_select_mode(true)
}
KeyCode::Char('p') if key.modifiers != KeyModifiers::CONTROL => {
self.paste_range()?;
}
KeyCode::Char('v') if key.modifiers == KeyModifiers::CONTROL => {
self.paste_range()?;
}
KeyCode::Char('h') if key.modifiers == KeyModifiers::ALT => {
self.enter_dialog_mode(self.render_help_text());
@ -695,14 +816,56 @@ impl<'ws> Workspace<'ws> {
Ok(())
})?;
}
KeyCode::Char('g') => {
// TODO(zaphar): This really needs a better state machine.
if self.state.char_queue.first().map(|c| *c == 'g').unwrap_or(false) {
self.state.char_queue.pop();
self.move_to_top()?;
} else {
self.state.char_queue.push('g');
}
}
_ => {
// noop
self.state.char_queue.clear();
}
}
}
return Ok(None);
}
fn paste_range(&mut self) -> Result<(), anyhow::Error> {
match &self.state.clipboard {
Some(ClipboardContents::Cell(contents)) => {
self.book.edit_current_cell(contents)?;
self.book.evaluate();
}
Some(ClipboardContents::Range(ref rows)) => {
let Address { row, col } = self.book.location.clone();
let row_len = rows.len();
for ri in 0..row_len {
let columns = &rows[ri];
let col_len = columns.len();
for ci in 0..col_len {
self.book.update_cell(
&Address {
row: ri + row,
col: ci + col,
},
columns[ci].clone(),
)?;
}
}
self.book.evaluate();
}
None => {
// NOOP
}
}
self.state.clipboard = None;
Ok(())
}
fn run_with_prefix(
&mut self,
action: impl Fn(&mut Workspace<'_>) -> std::result::Result<(), anyhow::Error>,
@ -726,11 +889,15 @@ impl<'ws> Workspace<'ws> {
self.state.modality_stack.push(Modality::Dialog);
}
fn enter_range_select_mode(&mut self) {
fn enter_range_select_mode(&mut self, init_start: bool) {
self.state.range_select.sheet = Some(self.book.current_sheet);
self.state.range_select.original_sheet = Some(self.book.current_sheet);
self.state.range_select.original_location = Some(self.book.location.clone());
self.state.range_select.start = None;
if init_start {
self.state.range_select.start = Some(self.book.location.clone());
} else {
self.state.range_select.start = None;
}
self.state.range_select.end = None;
self.state.modality_stack.push(Modality::RangeSelect);
}

View File

@ -28,25 +28,40 @@ impl<'ws> Workspace<'ws> {
];
let mut rs: Vec<Box<dyn Fn(Rect, &mut Buffer, &mut Self)>> = vec![
Box::new(|rect: Rect, buf: &mut Buffer, ws: &mut Self| {
let tabs = Tabs::new(ws.book.get_sheet_names().iter().enumerate().map(|(idx, name)| format!("{} {}", name, idx)).collect::<Vec<String>>())
.select(Some(ws.book.current_sheet as usize));
let tabs = Tabs::new(
ws.book
.get_sheet_names()
.iter()
.enumerate()
.map(|(idx, name)| format!("{} {}", name, idx))
.collect::<Vec<String>>(),
)
.select(Some(ws.book.current_sheet as usize));
tabs.render(rect, buf);
}),
Box::new(|rect: Rect, buf: &mut Buffer, ws: &mut Self| {
let [text_rect, info_rect] = Layout::horizontal(vec![Constraint::Fill(1),Constraint::Fill(1)]).areas(rect);
let [text_rect, info_rect] =
Layout::horizontal(vec![Constraint::Fill(1), Constraint::Fill(1)]).areas(rect);
ws.text_area.render(text_rect, buf);
let hint = Paragraph::new(vec![
Line::from(""),
Line::from("ALT-h to toggle help dialog").centered()
Line::from("ALT-h to toggle help dialog").centered(),
]);
hint.render(info_rect, buf);
}),
Box::new(move |rect: Rect, buf: &mut Buffer, ws: &mut Self| {
let sheet_name = ws.book.get_sheet_name().unwrap_or("Unknown");
let table_block = Block::bordered().title_top(sheet_name);
let viewport = Viewport::new(&ws.book, &ws.state.range_select)
.with_selected(ws.book.location.clone())
.block(table_block);
let viewport = Viewport::new(
&ws.book,
if ws.state.modality() == &Modality::RangeSelect {
Some(&ws.state.range_select)
} else {
None
},
)
.with_selected(ws.book.location.clone())
.block(table_block);
StatefulWidget::render(viewport, rect, buf, &mut ws.state.viewport_state);
}),
];

View File

@ -14,7 +14,7 @@ fn test_viewport_get_visible_columns() {
let width = dbg!(dbg!(default_size) * 12 / 2);
let app_state = AppState::default();
let viewport =
Viewport::new(&book, &app_state.range_select).with_selected(Address { row: 1, col: 17 });
Viewport::new(&book, Some(&app_state.range_select)).with_selected(Address { row: 1, col: 17 });
let cols = viewport
.get_visible_columns((width + 5) as u16, &mut state)
.expect("Failed to get visible columns");
@ -31,7 +31,7 @@ fn test_viewport_get_visible_rows() {
let height = 6;
let app_state = AppState::default();
let viewport =
Viewport::new(&book, &app_state.range_select).with_selected(Address { row: 17, col: 1 });
Viewport::new(&book, Some(&app_state.range_select)).with_selected(Address { row: 17, col: 1 });
let rows = dbg!(viewport.get_visible_rows(height as u16, &mut state));
assert_eq!(height - 1, rows.len());
assert_eq!(
@ -51,7 +51,7 @@ fn test_viewport_visible_columns_after_length_change() {
let width = dbg!(dbg!(default_size) * 12 / 2);
{
let app_state = AppState::default();
let viewport = Viewport::new(&book, &app_state.range_select)
let viewport = Viewport::new(&book, Some(&app_state.range_select))
.with_selected(Address { row: 1, col: 17 });
let cols = viewport
.get_visible_columns((width + 5) as u16, &mut state)
@ -65,7 +65,7 @@ fn test_viewport_visible_columns_after_length_change() {
{
let app_state = AppState::default();
let viewport =
Viewport::new(&book, &app_state.range_select).with_selected(Address { row: 1, col: 1 });
Viewport::new(&book, Some(&app_state.range_select)).with_selected(Address { row: 1, col: 1 });
let cols = viewport
.get_visible_columns((width + 5) as u16, &mut state)
.expect("Failed to get visible columns");

View File

@ -37,7 +37,7 @@ pub struct ViewportState {
pub struct Viewport<'ws> {
pub(crate) selected: Address,
book: &'ws Book,
range_selection: &'ws RangeSelection,
range_selection: Option<&'ws RangeSelection>,
block: Option<Block<'ws>>,
}
@ -47,7 +47,7 @@ pub(crate) const COLNAMES: [&'static str; 26] = [
];
impl<'ws> Viewport<'ws> {
pub fn new(book: &'ws Book, app_state: &'ws RangeSelection) -> Self {
pub fn new(book: &'ws Book, app_state: Option<&'ws RangeSelection>) -> Self {
Self {
book,
range_selection: app_state,
@ -155,13 +155,14 @@ impl<'ws> Viewport<'ws> {
let mut cells = vec![Cell::new(Text::from(ri.to_string()))];
cells.extend(visible_columns.iter().map(
|VisibleColumn { idx: ci, length: _ }| {
// TODO(zaphar): Is this safe?
let content = self
.book
.get_cell_addr_rendered(&Address { row: ri, col: *ci })
.unwrap();
let mut cell = Cell::new(Text::raw(content));
if let Some((start, end)) = &self.range_selection.get_range() {
if let Some((start, end)) =
&self.range_selection.map_or(None, |r| r.get_range())
{
if ri >= start.row
&& ri <= end.row
&& *ci >= start.col

View File

@ -35,7 +35,7 @@ fn test_insert_rows_cmd() {
let output = result.unwrap();
assert!(output.is_some());
let cmd = output.unwrap();
assert_eq!(cmd, Cmd::InsertRow(1));
assert_eq!(cmd, Cmd::InsertRows(1));
}
#[test]
@ -46,7 +46,7 @@ fn test_insert_rows_cmd_short() {
let output = result.unwrap();
assert!(output.is_some());
let cmd = output.unwrap();
assert_eq!(cmd, Cmd::InsertRow(1));
assert_eq!(cmd, Cmd::InsertRows(1));
}
#[test]
@ -200,7 +200,6 @@ fn construct_modified_key_event(code: KeyCode, mods: KeyModifiers) -> Event {
Event::Key(KeyEvent::new(code, mods))
}
// TODO(zaphar): Interaction testing for input.
#[test]
fn test_input_navitation_enter_key() {
let mut ws =
@ -425,3 +424,22 @@ fn test_range_copy_mode_from_edit_mode() {
.expect("Failed to handle 'Ctrl-r' key event");
assert_eq!(Some(&Modality::RangeSelect), ws.state.modality_stack.last());
}
#[test]
fn test_gg_movement() {
let mut ws =
Workspace::new_empty("en", "America/New_York").expect("Failed to get empty workbook");
assert_eq!(Some(&Modality::Navigate), ws.state.modality_stack.last());
ws.handle_input(construct_key_event(KeyCode::Char('j')))
.expect("Failed to handle 'e' key event");
ws.handle_input(construct_key_event(KeyCode::Char('j')))
.expect("Failed to handle 'e' key event");
assert_eq!(ws.book.location, Address { row: 3, col: 1 });
ws.handle_input(construct_key_event(KeyCode::Char('l')))
.expect("Failed to handle 'e' key event");
ws.handle_input(construct_key_event(KeyCode::Char('g')))
.expect("Failed to handle 'e' key event");
ws.handle_input(construct_key_event(KeyCode::Char('g')))
.expect("Failed to handle 'e' key event");
assert_eq!(ws.book.location, Address { row: 1, col: 2 });
}