Merge 1aa90255e49d994e6624eec32da1134c6d17c996 into 31037752a88d0315b912bcf5bf61853acfb7037f

This commit is contained in:
Jeremy Wall 2024-12-05 12:30:41 -05:00 committed by GitHub
commit be1f26ee7d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 273 additions and 4 deletions

View File

@ -76,6 +76,9 @@ 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-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
@ -94,3 +97,15 @@ The currently supported commands are:
* `quit` Quits the application. `q` is a shorthand alias for this command.
<aside>Note that in the case of `quit` and `edit` that we do not currently prompt you if the current spreadsheet has not been saved yet. So your changes will be discarded if you have not saved first.</aside>
### 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.
<aside>We only support continuous ranges for the moment. Planned for discontinuous ranges still needs the interaction interface to be determined.</aside>

View File

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

View File

@ -30,6 +30,7 @@ pub enum Modality {
CellEdit,
Command,
Dialog,
RangeSelect,
}
#[derive(Debug)]
@ -38,6 +39,11 @@ pub struct AppState<'ws> {
pub viewport_state: ViewportState,
pub command_state: TextState<'ws>,
pub numeric_prefix: Vec<char>,
pub original_location: Option<Address>,
pub original_sheet: Option<u32>,
pub range_sheet: Option<u32>,
pub start_range: Option<Address>,
pub end_range: Option<Address>,
dirty: bool,
popup: Vec<String>,
}
@ -49,6 +55,11 @@ impl<'ws> Default for AppState<'ws> {
viewport_state: Default::default(),
command_state: Default::default(),
numeric_prefix: Default::default(),
original_location: Default::default(),
original_sheet: Default::default(),
range_sheet: Default::default(),
start_range: Default::default(),
end_range: Default::default(),
dirty: Default::default(),
popup: Default::default(),
}
@ -97,6 +108,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 +173,33 @@ impl<'ws> Workspace<'ws> {
Ok(())
}
pub fn selected_range_to_string(&self) -> String {
let state = &self.state;
let start = state
.start_range
.as_ref()
.map(|addr| addr.to_range_part())
.unwrap_or_else(|| String::new());
let end = state
.end_range
.as_ref()
.map(|addr| format!(":{}", addr.to_range_part()))
.unwrap_or_else(|| String::new());
if let Some(range_sheet) = state.range_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"),
start,
end
);
}
}
format!("{}:{}", start, end)
}
/// Move a row down in the current sheet.
pub fn move_down(&mut self) -> Result<()> {
let mut loc = self.book.location.clone();
@ -197,6 +248,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 +269,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 +286,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 +337,15 @@ 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();
return Ok(None);
}
KeyCode::Enter => self.exit_edit_mode(true)?,
KeyCode::Esc => self.exit_edit_mode(false)?,
_ => {
@ -363,6 +433,73 @@ impl<'ws> Workspace<'ws> {
self.state.numeric_prefix.push(digit);
}
fn handle_range_select_input(&mut self, key: event::KeyEvent) -> Result<Option<ExitCode>> {
if key.kind == KeyEventKind::Press {
match key.code {
KeyCode::Esc => {
self.state.reset_n_prefix();
}
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(())
})?;
}
KeyCode::Char('j') => {
self.run_with_prefix(|ws: &mut Workspace<'_>| -> Result<()> {
ws.move_down()?;
Ok(())
})?;
}
KeyCode::Char('k') => {
self.run_with_prefix(|ws: &mut Workspace<'_>| -> Result<()> {
ws.move_up()?;
Ok(())
})?;
}
KeyCode::Char('l') => {
self.run_with_prefix(|ws: &mut Workspace<'_>| -> Result<()> {
ws.move_right()?;
Ok(())
})?;
}
KeyCode::Char(' ') => {
if self.state.start_range.is_none() {
self.state.start_range = Some(self.book.location.clone());
} else {
self.state.end_range = Some(self.book.location.clone());
self.exit_range_select_mode()?;
}
}
KeyCode::Char('n') if key.modifiers == KeyModifiers::CONTROL => {
self.run_with_prefix(|ws: &mut Workspace<'_>| -> Result<()> {
ws.book.select_next_sheet();
Ok(())
})?;
self.state.range_sheet = Some(self.book.current_sheet);
}
KeyCode::Char('p') if key.modifiers == KeyModifiers::CONTROL => {
self.run_with_prefix(|ws: &mut Workspace<'_>| -> Result<()> {
ws.book.select_prev_sheet();
Ok(())
})?;
self.state.range_sheet = Some(self.book.current_sheet);
}
_ => {
// moop
}
}
}
Ok(None)
}
fn handle_navigation_input(&mut self, key: event::KeyEvent) -> Result<Option<ExitCode>> {
if key.kind == KeyEventKind::Press {
match key.code {
@ -381,6 +518,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 +627,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)?;
}
@ -511,6 +654,15 @@ impl<'ws> Workspace<'ws> {
self.state.modality_stack.push(Modality::Dialog);
}
fn enter_range_select_mode(&mut self) {
self.state.range_sheet = Some(self.book.current_sheet);
self.state.original_sheet = Some(self.book.current_sheet);
self.state.original_location = Some(self.book.location.clone());
self.state.start_range = None;
self.state.end_range = None;
self.state.modality_stack.push(Modality::RangeSelect);
}
fn enter_edit_mode(&mut self) {
self.state.modality_stack.push(Modality::CellEdit);
self.text_area
@ -535,6 +687,26 @@ impl<'ws> Workspace<'ws> {
Ok(())
}
fn exit_range_select_mode(&mut self) -> Result<()> {
self.book.current_sheet = self
.state
.original_sheet
.clone()
.expect("Missing original sheet");
self.book.location = self
.state
.original_location
.clone()
.expect("Missing original location after range copy");
self.state.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();
}
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());
@ -546,7 +718,7 @@ impl<'ws> Workspace<'ws> {
self.text_area = reset_text_area(self.book.get_current_cell_contents()?);
}
self.state.dirty = false;
self.enter_navigation_mode();
self.state.pop_modality();
Ok(())
}

View File

@ -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!(

View File

@ -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.original_location);
assert!(ws.state.start_range.is_none());
assert!(ws.state.end_range.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.start_range);
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.original_location.is_none());
assert_eq!(Some(Address {row:1, col:2, }), ws.state.start_range);
assert_eq!(Some(Address {row:2, col:2, }), ws.state.end_range);
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.original_location);
assert!(ws.state.start_range.is_none());
assert!(ws.state.end_range.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.start_range);
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.original_location.is_none());
assert_eq!(Some(Address {row:5, col:4, }), ws.state.start_range);
assert_eq!(Some(Address {row:4, col:4, }), ws.state.end_range);
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());
}