diff --git a/docs/index.md b/docs/index.md index 83db44d..aea95d9 100644 --- a/docs/index.md +++ b/docs/index.md @@ -22,11 +22,14 @@ Options: ## User Interface -The sheetui user interface is loosely inspired by vim. It is a modal interface that is entirely keyboard driven. At nearly any time you can type `Alt-h` to get some context sensitive help. +The sheetui user interface is loosely inspired by vim. It is a modal interface +that is entirely keyboard driven. At nearly any time you can type `Alt-h` to +get some context sensitive help. ### Navigation Mode -The interface will start out in navigation mode. You can navigate around the table and between the sheets using the following keybinds: +The interface will start out in navigation mode. You can navigate around the +table and between the sheets using the following keybinds: **Cell Navigation** @@ -44,7 +47,9 @@ Sheet navigation moving will loop around when you reach the ends. **Numeric prefixes** -You can prefix each of the keybinds above with a numeric prefix to do them that many times. So typing `123h` will move to the left 123 times. Hitting `Esc` will clear the numeric prefix if you want to cancel it. +You can prefix each of the keybinds above with a numeric prefix to do them that +many times. So typing `123h` will move to the left 123 times. Hitting `Esc` +will clear the numeric prefix if you want to cancel it. **Modifying the Sheet or Cells** @@ -54,33 +59,51 @@ You can prefix each of the keybinds above with a numeric prefix to do them that **Other Keybindings** +* `Ctrl-r` will enter range selection mode * `Ctrl-s` will save the sheet. * `q` will exit the application. * `:` will enter CommandMode. - - + +Range selections made from navigation mode will be available to paste into a Cell Edit. + + ### CellEdit Mode -You enter CellEdit mode by hitting `e` or `i` while in navigation mode. Type what you want into the cell. +You enter CellEdit mode by hitting `e` or `i` while in navigation mode. Type +what you want into the cell. Starting with: * `=` will treat what you type as a formula. * `$` will treat it as us currency. -Typing a number will treat the contents as a number. While typing non-numeric text will treat it as text content. +Typing a number will treat the contents as a number. While typing non-numeric +text will treat it as text content. -For the most part this should work the same way you expect a spreadsheet to work. + + +For the most part this should work the same way you expect a spreadsheet to +work. * `Enter` will update the cell contents. * `Esc` will cancel editing the cell and leave it unedited. +* `Ctrl-p` will paste the range selection if it exists into the cell. -You can find the functions we support documented here: [ironcalc docs](https://docs.ironcalc.com/functions/lookup-and-reference.html) +`Ctrl-r` will enter range select mode when editing a formula. You can navigate +around the sheet and hit space to select that cell in the sheet to set the +start of the range. Navigate some more and hit space to set the end of the +range. + +You can find the functions we support documented here: +[ironcalc docs](https://docs.ironcalc.com/functions/lookup-and-reference.html) ### Command Mode -You enter command mode by typing `:` while in navigation mode. You can then type a command and hit `Enter` to execute it or `Esc` to cancel. +You enter command mode by typing `:` while in navigation mode. You can then +type a command and hit `Enter` to execute it or `Esc` to cancel. The currently supported commands are: @@ -93,4 +116,22 @@ The currently supported commands are: * `edit ` Edit a new spreadsheet at the current path. `e` is a shorthand alias for this command. * `quit` Quits the application. `q` is a shorthand alias for this command. - + + +### Range Select Mode + +Range Select mode copies a range reference for use later. You can enter range +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. +* `The spacebar will select the start and end of the range respectively. + +When you have selected the end of the range you will exit range select mode and +the range reference will be placed into the cell contents you are editing. + + diff --git a/src/book/mod.rs b/src/book/mod.rs index 5e481b7..8602ea2 100644 --- a/src/book/mod.rs +++ b/src/book/mod.rs @@ -287,6 +287,13 @@ impl Book { .map_err(|s| anyhow!("Invalid Worksheet: {}", s))?) } + pub(crate) fn get_sheet_name_by_idx(&self, idx: usize) -> Result<&str> { + Ok(&self + .model + .workbook + .worksheet(idx as u32) + .map_err(|s| anyhow!("Invalid Worksheet: {}", s))?.name) + } pub(crate) fn get_sheet_by_idx_mut(&mut self, idx: usize) -> Result<&mut Worksheet> { Ok(self .model diff --git a/src/ui/mod.rs b/src/ui/mod.rs index c12b2a7..01ff5c1 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -30,6 +30,40 @@ pub enum Modality { CellEdit, Command, Dialog, + RangeSelect, +} + +#[derive(Debug, Default)] +pub struct RangeSelection { + pub original_location: Option
, + pub original_sheet: Option, + pub sheet: Option, + pub start: Option
, + pub end: Option
, +} + +impl RangeSelection { + pub fn get_range(&self) -> Option<(Address, Address)> { + if let (Some(start), Some(end)) = (&self.start, &self.end) { + return Some(( + Address { + row: std::cmp::min(start.row, end.row), + col: std::cmp::min(start.col, end.col), + }, + Address { + row: std::cmp::max(start.row, end.row), + col: std::cmp::max(start.col, end.col), + }, + )); + } + None + } + + pub fn reset_range_selection(&mut self) { + self.start = None; + self.end = None; + self.sheet = None; + } } #[derive(Debug)] @@ -38,6 +72,7 @@ pub struct AppState<'ws> { pub viewport_state: ViewportState, pub command_state: TextState<'ws>, pub numeric_prefix: Vec, + pub range_select: RangeSelection, dirty: bool, popup: Vec, } @@ -49,6 +84,7 @@ impl<'ws> Default for AppState<'ws> { viewport_state: Default::default(), command_state: Default::default(), numeric_prefix: Default::default(), + range_select: Default::default(), dirty: Default::default(), popup: Default::default(), } @@ -97,6 +133,19 @@ impl Address { pub fn new(row: usize, col: usize) -> Self { Self { row, col } } + + pub fn to_range_part(&self) -> String { + let count = if self.col == 26 { + 1 + } else { + (self.col / 26) + 1 + }; + format!( + "{}{}", + render::viewport::COLNAMES[(self.col - 1) % 26].repeat(count), + self.row + ) + } } impl Default for Address { @@ -149,6 +198,26 @@ impl<'ws> Workspace<'ws> { Ok(()) } + pub fn selected_range_to_string(&self) -> String { + let state = &self.state; + if let Some((start, end)) = state.range_select.get_range() { + let a1 = format!("{}{}", start.to_range_part(), format!(":{}", end.to_range_part())); + if let Some(range_sheet) = state.range_select.sheet { + if range_sheet != self.book.current_sheet { + return format!( + "{}!{}", + self.book + .get_sheet_name_by_idx(range_sheet as usize) + .expect("No such sheet index"), + a1 + ); + } + } + return a1; + } + return String::new() + } + /// Move a row down in the current sheet. pub fn move_down(&mut self) -> Result<()> { let mut loc = self.book.location.clone(); @@ -197,6 +266,7 @@ impl<'ws> Workspace<'ws> { Modality::CellEdit => self.handle_edit_input(key)?, Modality::Command => self.handle_command_input(key)?, Modality::Dialog => self.handle_dialog_input(key)?, + Modality::RangeSelect => self.handle_range_select_input(key)?, }; return Ok(result); } @@ -217,13 +287,14 @@ impl<'ws> Workspace<'ws> { "* CTRl-h: Shrink column width by 1".to_string(), "* CTRl-n: Next sheet. Starts over at beginning if at end.".to_string(), "* CTRl-p: Previous sheet. Starts over at end if at beginning.".to_string(), - "* CTRl-?: Previous sheet. Starts over at end if at beginning.".to_string(), + "* ALT-h: Previous sheet. Starts over at end if at beginning.".to_string(), "* q exit".to_string(), "* Ctrl-S Save sheet".to_string(), ], Modality::CellEdit => vec![ "Edit Mode:".to_string(), "* ENTER/RETURN: Exit edit mode and save changes".to_string(), + "* Ctrl-r: Enter Range Selection mode".to_string(), "* ESC: Exit edit mode and discard changes".to_string(), "Otherwise edit as normal".to_string(), ], @@ -233,6 +304,14 @@ impl<'ws> Workspace<'ws> { "* CTRL-?: Exit command mode".to_string(), "* ENTER/RETURN: run command and exit command mode".to_string(), ], + Modality::RangeSelect => vec![ + "Range Selection Mode:".to_string(), + "* ESC: Exit command mode".to_string(), + "* h,j,k,l: vim style navigation".to_string(), + "* Spacebar: Select start and end of range".to_string(), + "* CTRl-n: Next sheet. Starts over at beginning if at end.".to_string(), + "* CTRl-p: Previous sheet. Starts over at end if at beginning.".to_string(), + ], _ => vec!["General help".to_string()], } } @@ -276,6 +355,17 @@ impl<'ws> Workspace<'ws> { self.enter_dialog_mode(self.render_help_text()); return Ok(None); } + KeyCode::Char('r') if key.modifiers == KeyModifiers::CONTROL => { + self.enter_range_select_mode(); + return Ok(None); + } + KeyCode::Char('p') if key.modifiers == KeyModifiers::CONTROL => { + self.text_area + .set_yank_text(self.selected_range_to_string()); + self.text_area.paste(); + self.state.dirty = true; + return Ok(None); + } KeyCode::Enter => self.exit_edit_mode(true)?, KeyCode::Esc => self.exit_edit_mode(false)?, _ => { @@ -363,6 +453,91 @@ impl<'ws> Workspace<'ws> { self.state.numeric_prefix.push(digit); } + fn handle_range_select_input(&mut self, key: event::KeyEvent) -> Result> { + if key.kind == KeyEventKind::Press { + match key.code { + KeyCode::Esc => { + if self.state.numeric_prefix.len() > 0 { + self.state.reset_n_prefix(); + } else { + self.state.range_select.start = None; + self.state.range_select.end = None; + self.exit_range_select_mode()?; + } + } + KeyCode::Char('h') if key.modifiers == KeyModifiers::ALT => { + self.enter_dialog_mode(self.render_help_text()); + return Ok(None); + } + KeyCode::Char(d) if d.is_ascii_digit() => { + self.handle_numeric_prefix(d); + } + KeyCode::Char('h') => { + self.run_with_prefix(|ws: &mut Workspace<'_>| -> Result<()> { + ws.move_left()?; + Ok(()) + })?; + self.maybe_update_range_end(); + } + KeyCode::Char('j') => { + self.run_with_prefix(|ws: &mut Workspace<'_>| -> Result<()> { + ws.move_down()?; + Ok(()) + })?; + self.maybe_update_range_end(); + } + KeyCode::Char('k') => { + self.run_with_prefix(|ws: &mut Workspace<'_>| -> Result<()> { + ws.move_up()?; + Ok(()) + })?; + self.maybe_update_range_end(); + } + KeyCode::Char('l') => { + self.run_with_prefix(|ws: &mut Workspace<'_>| -> Result<()> { + ws.move_right()?; + Ok(()) + })?; + 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()?; + } + } + KeyCode::Char('n') if key.modifiers == KeyModifiers::CONTROL => { + self.state.range_select.reset_range_selection(); + self.run_with_prefix(|ws: &mut Workspace<'_>| -> Result<()> { + ws.book.select_next_sheet(); + Ok(()) + })?; + self.state.range_select.sheet = Some(self.book.current_sheet); + } + KeyCode::Char('p') if key.modifiers == KeyModifiers::CONTROL => { + self.state.range_select.reset_range_selection(); + self.run_with_prefix(|ws: &mut Workspace<'_>| -> Result<()> { + ws.book.select_prev_sheet(); + Ok(()) + })?; + self.state.range_select.sheet = Some(self.book.current_sheet); + } + _ => { + // moop + } + } + } + Ok(None) + } + + 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()); + } + } + fn handle_navigation_input(&mut self, key: event::KeyEvent) -> Result> { if key.kind == KeyEventKind::Press { match key.code { @@ -381,6 +556,9 @@ impl<'ws> Workspace<'ws> { KeyCode::Char('s') if key.modifiers == KeyModifiers::CONTROL => { self.save_file()?; } + KeyCode::Char('r') if key.modifiers == KeyModifiers::CONTROL => { + self.enter_range_select_mode(); + } KeyCode::Char('h') if key.modifiers == KeyModifiers::ALT => { self.enter_dialog_mode(self.render_help_text()); } @@ -487,7 +665,10 @@ impl<'ws> Workspace<'ws> { return Ok(None); } - fn run_with_prefix(&mut self, action: impl Fn(&mut Workspace<'_>) -> std::result::Result<(), anyhow::Error>) -> Result<(), anyhow::Error> { + fn run_with_prefix( + &mut self, + action: impl Fn(&mut Workspace<'_>) -> std::result::Result<(), anyhow::Error>, + ) -> Result<(), anyhow::Error> { for _ in 1..=self.state.get_n_prefix() { action(self)?; } @@ -507,6 +688,15 @@ impl<'ws> Workspace<'ws> { self.state.modality_stack.push(Modality::Dialog); } + fn enter_range_select_mode(&mut self) { + 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; + self.state.range_select.end = None; + self.state.modality_stack.push(Modality::RangeSelect); + } + fn enter_edit_mode(&mut self) { self.state.modality_stack.push(Modality::CellEdit); self.text_area @@ -531,6 +721,30 @@ impl<'ws> Workspace<'ws> { Ok(()) } + fn exit_range_select_mode(&mut self) -> Result<()> { + self.book.current_sheet = self + .state + .range_select + .original_sheet + .clone() + .expect("Missing original sheet"); + self.book.location = self + .state + .range_select + .original_location + .clone() + .expect("Missing original location after range copy"); + self.state.range_select.original_location = None; + self.state.pop_modality(); + if self.state.modality() == &Modality::CellEdit { + self.text_area + .set_yank_text(self.selected_range_to_string()); + self.text_area.paste(); + self.state.dirty = true; + } + Ok(()) + } + fn exit_edit_mode(&mut self, keep: bool) -> Result<()> { self.text_area.set_cursor_line_style(Style::default()); self.text_area.set_cursor_style(Style::default()); @@ -538,10 +752,10 @@ impl<'ws> Workspace<'ws> { if self.state.dirty && keep { self.book.edit_current_cell(contents)?; self.book.evaluate(); - } else { - self.text_area = reset_text_area(self.book.get_current_cell_contents()?); } + self.text_area = reset_text_area(self.book.get_current_cell_contents()?); self.state.dirty = false; + self.state.pop_modality(); Ok(()) } diff --git a/src/ui/render/mod.rs b/src/ui/render/mod.rs index 74f00c1..bedf8e6 100644 --- a/src/ui/render/mod.rs +++ b/src/ui/render/mod.rs @@ -44,7 +44,7 @@ impl<'ws> Workspace<'ws> { 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) + let viewport = Viewport::new(&ws.book, &ws.state.range_select) .with_selected(ws.book.location.clone()) .block(table_block); StatefulWidget::render(viewport, rect, buf, &mut ws.state.viewport_state); @@ -95,6 +95,7 @@ impl<'widget, 'ws: 'widget> Widget for &'widget mut Workspace<'ws> { Modality::CellEdit => "edit", Modality::Command => "command", Modality::Dialog => "", + Modality::RangeSelect => "range-copy", }) .title_bottom( Line::from(format!( diff --git a/src/ui/render/test.rs b/src/ui/render/test.rs index 68a5006..001e389 100644 --- a/src/ui/render/test.rs +++ b/src/ui/render/test.rs @@ -1,15 +1,23 @@ use ironcalc::base::Model; +use crate::ui::AppState; + use super::{Address, Book, Viewport, ViewportState}; #[test] fn test_viewport_get_visible_columns() { let mut state = ViewportState::default(); - let book = Book::new(Model::new_empty("test", "en", "America/New_York").expect("Failed to make model")); + let book = Book::new( + Model::new_empty("test", "en", "America/New_York").expect("Failed to make model"), + ); let default_size = book.get_col_size(1).expect("Failed to get column size"); let width = dbg!(dbg!(default_size) * 12 / 2); - let viewport = Viewport::new(&book).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"); + let app_state = AppState::default(); + let viewport = + Viewport::new(&book, &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"); assert_eq!(5, cols.len()); assert_eq!(17, cols.last().expect("Failed to get last column").idx); } @@ -17,32 +25,50 @@ fn test_viewport_get_visible_columns() { #[test] fn test_viewport_get_visible_rows() { let mut state = dbg!(ViewportState::default()); - let book = Book::new(Model::new_empty("test", "en", "America/New_York").expect("Failed to make model")); + let book = Book::new( + Model::new_empty("test", "en", "America/New_York").expect("Failed to make model"), + ); let height = 6; - let viewport = Viewport::new(&book).with_selected(Address { row: 17, col: 1 }); + let app_state = AppState::default(); + let viewport = + Viewport::new(&book, &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!(17 - (height - 2), *rows.first().expect("Failed to get first row")); + assert_eq!( + 17 - (height - 2), + *rows.first().expect("Failed to get first row") + ); assert_eq!(17, *rows.last().expect("Failed to get last row")); } #[test] fn test_viewport_visible_columns_after_length_change() { let mut state = ViewportState::default(); - let mut book = Book::new(Model::new_empty("test", "en", "America/New_York").expect("Failed to make model")); + let mut book = Book::new( + Model::new_empty("test", "en", "America/New_York").expect("Failed to make model"), + ); let default_size = book.get_col_size(1).expect("Failed to get column size"); let width = dbg!(dbg!(default_size) * 12 / 2); { - let viewport = Viewport::new(&book).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"); + let app_state = AppState::default(); + let viewport = Viewport::new(&book, &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"); assert_eq!(5, cols.len()); assert_eq!(17, cols.last().expect("Failed to get last column").idx); } - book.set_col_size(1, default_size * 6).expect("Failed to set column size"); + book.set_col_size(1, default_size * 6) + .expect("Failed to set column size"); { - let viewport = Viewport::new(&book).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"); + let app_state = AppState::default(); + let viewport = + Viewport::new(&book, &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"); assert_eq!(1, cols.len()); assert_eq!(1, cols.last().expect("Failed to get last column").idx); } diff --git a/src/ui/render/viewport.rs b/src/ui/render/viewport.rs index 38b7653..32de8b6 100644 --- a/src/ui/render/viewport.rs +++ b/src/ui/render/viewport.rs @@ -7,7 +7,7 @@ use ratatui::{ widgets::{Block, Cell, Row, StatefulWidget, Table, Widget}, }; -use super::{Address, Book}; +use super::{Address, Book, RangeSelection}; // TODO(zaphar): Move this to the book module. // NOTE(zaphar): This is stolen from ironcalc but ironcalc doesn't expose it @@ -34,10 +34,11 @@ pub struct ViewportState { } /// A renderable viewport over a book. -pub struct Viewport<'book> { +pub struct Viewport<'ws> { pub(crate) selected: Address, - book: &'book Book, - block: Option>, + book: &'ws Book, + range_selection: &'ws RangeSelection, + block: Option>, } pub(crate) const COLNAMES: [&'static str; 26] = [ @@ -45,10 +46,11 @@ pub(crate) const COLNAMES: [&'static str; 26] = [ "T", "U", "V", "W", "X", "Y", "Z", ]; -impl<'book> Viewport<'book> { - pub fn new(book: &'book Book) -> Self { +impl<'ws> Viewport<'ws> { + pub fn new(book: &'ws Book, app_state: &'ws RangeSelection) -> Self { Self { book, + range_selection: app_state, selected: Default::default(), block: None, } @@ -127,7 +129,7 @@ impl<'book> Viewport<'book> { return Ok(visible); } - pub fn block(mut self, block: Block<'book>) -> Self { + pub fn block(mut self, block: Block<'ws>) -> Self { self.block = Some(block); self } @@ -158,7 +160,17 @@ impl<'book> Viewport<'book> { .book .get_cell_addr_rendered(&Address { row: ri, col: *ci }) .unwrap(); - let cell = Cell::new(Text::raw(content)); + let mut cell = Cell::new(Text::raw(content)); + if let Some((start, end)) = &self.range_selection.get_range() { + if ri >= start.row + && ri <= end.row + && *ci >= start.col + && *ci <= end.col + { + // This is a selected range + cell = cell.fg(Color::Black).bg(Color::LightBlue) + } + } match (self.book.location.row == ri, self.book.location.col == *ci) { (true, true) => cell.fg(Color::White).bg(Color::Rgb(57, 61, 71)), _ => cell, @@ -197,7 +209,7 @@ impl<'book> Viewport<'book> { } } -impl<'book> StatefulWidget for Viewport<'book> { +impl<'ws> StatefulWidget for Viewport<'ws> { type State = ViewportState; fn render(self, area: Rect, buf: &mut Buffer, state: &mut Self::State) { diff --git a/src/ui/test.rs b/src/ui/test.rs index dbba849..47ed76e 100644 --- a/src/ui/test.rs +++ b/src/ui/test.rs @@ -1,6 +1,6 @@ use crossterm::event::{Event, KeyCode, KeyEvent, KeyModifiers}; -use crate::ui::Modality; +use crate::ui::{Address, Modality}; use super::cmd::{parse, Cmd}; use super::Workspace; @@ -351,3 +351,77 @@ fn test_navigation_tab_next_numeric_prefix() .expect("Failed to handle 'Ctrl-n' key event"); assert_eq!("Sheet1", ws.book.get_sheet_name().expect("Failed to get sheet name")); } + +#[test] +fn test_range_copy() { + 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.book.move_to(&Address { row: 1, col: 1, }).expect("Failed to move to row"); + let original_loc = ws.book.location.clone(); + ws.handle_input(construct_modified_key_event(KeyCode::Char('r'), KeyModifiers::CONTROL)) + .expect("Failed to handle 'Ctrl-r' key event"); + assert_eq!(Some(&Modality::RangeSelect), ws.state.modality_stack.last()); + assert_eq!(Some(original_loc.clone()), ws.state.range_select.original_location); + assert!(ws.state.range_select.start.is_none()); + assert!(ws.state.range_select.end.is_none()); + + ws.handle_input(construct_key_event(KeyCode::Char('l'))) + .expect("Failed to handle 'l' key event"); + ws.handle_input(construct_key_event(KeyCode::Char(' '))) + .expect("Failed to handle ' ' key event"); + assert_eq!(Some(Address {row:1, col:2, }), ws.state.range_select.start); + + ws.handle_input(construct_key_event(KeyCode::Char('j'))) + .expect("Failed to handle 'j' key event"); + ws.handle_input(construct_key_event(KeyCode::Char(' '))) + .expect("Failed to handle ' ' key event"); + + assert!(ws.state.range_select.original_location.is_none()); + assert_eq!(Some(Address {row:1, col:2, }), ws.state.range_select.start); + assert_eq!(Some(Address {row:2, col:2, }), ws.state.range_select.end); + assert_eq!(original_loc, ws.book.location); + assert_eq!(Some(&Modality::Navigate), ws.state.modality_stack.last()); + + ws.book.move_to(&Address { row: 5, col: 5, }).expect("Failed to move to row"); + let original_loc_2 = ws.book.location.clone(); + assert_eq!(Address { row: 5, col: 5 }, original_loc_2); + + ws.handle_input(construct_modified_key_event(KeyCode::Char('r'), KeyModifiers::CONTROL)) + .expect("Failed to handle 'Ctrl-r' key event"); + assert_eq!(Some(&Modality::RangeSelect), ws.state.modality_stack.last()); + assert_eq!(Some(original_loc_2.clone()), ws.state.range_select.original_location); + assert!(ws.state.range_select.start.is_none()); + assert!(ws.state.range_select.end.is_none()); + + ws.handle_input(construct_key_event(KeyCode::Char('h'))) + .expect("Failed to handle 'h' key event"); + ws.handle_input(construct_key_event(KeyCode::Char(' '))) + .expect("Failed to handle ' ' key event"); + assert_eq!(Some(Address {row:5, col:4, }), ws.state.range_select.start); + + ws.handle_input(construct_key_event(KeyCode::Char('k'))) + .expect("Failed to handle 'k' key event"); + ws.handle_input(construct_key_event(KeyCode::Char(' '))) + .expect("Failed to handle ' ' key event"); + + assert!(ws.state.range_select.original_location.is_none()); + assert_eq!(Some(Address {row:5, col:4, }), ws.state.range_select.start); + assert_eq!(Some(Address {row:4, col:4, }), ws.state.range_select.end); + assert_eq!(original_loc_2, ws.book.location); + assert_eq!(Some(&Modality::Navigate), ws.state.modality_stack.last()); +} + +#[test] +fn test_range_copy_mode_from_edit_mode() { + 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('e'))) + .expect("Failed to handle 'e' key event"); + assert_eq!(Some(&Modality::CellEdit), ws.state.modality_stack.last()); + ws.handle_input(construct_modified_key_event(KeyCode::Char('r'), KeyModifiers::CONTROL)) + .expect("Failed to handle 'Ctrl-r' key event"); + assert_eq!(Some(&Modality::RangeSelect), ws.state.modality_stack.last()); +}