mirror of
https://github.com/zaphar/sheetsui.git
synced 2025-07-23 13:29:48 -04:00
Compare commits
7 Commits
1aa90255e4
...
a85c5121ee
Author | SHA1 | Date | |
---|---|---|---|
a85c5121ee | |||
4b3e25895d | |||
61a6e93515 | |||
5f6f45141c | |||
bb5d81106e | |||
c62fd08043 | |||
626748db0f |
@ -22,11 +22,14 @@ Options:
|
|||||||
|
|
||||||
## User Interface
|
## 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
|
### 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**
|
**Cell Navigation**
|
||||||
|
|
||||||
@ -44,7 +47,9 @@ Sheet navigation moving will loop around when you reach the ends.
|
|||||||
|
|
||||||
**Numeric prefixes**
|
**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**
|
**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**
|
**Other Keybindings**
|
||||||
|
|
||||||
|
* `Ctrl-r` will enter range selection mode
|
||||||
* `Ctrl-s` will save the sheet.
|
* `Ctrl-s` will save the sheet.
|
||||||
* `q` will exit the application.
|
* `q` will exit the application.
|
||||||
* `:` will enter CommandMode.
|
* `:` will enter CommandMode.
|
||||||
|
|
||||||
<aside>Note that for `q` this will not currently prompt you if the sheet is not saved.</aside>
|
Range selections made from navigation mode will be available to paste into a Cell Edit.
|
||||||
|
|
||||||
|
<aside>Note that for `q` this will not currently prompt you if the sheet is not
|
||||||
|
saved.</aside>
|
||||||
|
|
||||||
### CellEdit Mode
|
### 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:
|
Starting with:
|
||||||
|
|
||||||
* `=` will treat what you type as a formula.
|
* `=` will treat what you type as a formula.
|
||||||
* `$` will treat it as us currency.
|
* `$` 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. <aside>We do not yet support modifying the type of a cell after the fact. We may add this in the future.</aside>
|
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.
|
<aside>We do not yet support modifying the type of a cell after the fact. We
|
||||||
|
may add this in the future.</aside>
|
||||||
|
|
||||||
|
For the most part this should work the same way you expect a spreadsheet to
|
||||||
|
work.
|
||||||
|
|
||||||
* `Enter` will update the cell contents.
|
* `Enter` will update the cell contents.
|
||||||
* `Esc` will cancel editing the cell and leave it unedited.
|
* `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
|
### 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:
|
The currently supported commands are:
|
||||||
|
|
||||||
@ -93,4 +116,22 @@ The currently supported commands are:
|
|||||||
* `edit <path>` Edit a new spreadsheet at the current path. `e` is a shorthand alias for this command.
|
* `edit <path>` 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.
|
* `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>
|
<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>
|
||||||
|
@ -287,6 +287,13 @@ impl Book {
|
|||||||
.map_err(|s| anyhow!("Invalid Worksheet: {}", s))?)
|
.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> {
|
pub(crate) fn get_sheet_by_idx_mut(&mut self, idx: usize) -> Result<&mut Worksheet> {
|
||||||
Ok(self
|
Ok(self
|
||||||
.model
|
.model
|
||||||
|
227
src/ui/mod.rs
227
src/ui/mod.rs
@ -30,6 +30,40 @@ pub enum Modality {
|
|||||||
CellEdit,
|
CellEdit,
|
||||||
Command,
|
Command,
|
||||||
Dialog,
|
Dialog,
|
||||||
|
RangeSelect,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Default)]
|
||||||
|
pub struct RangeSelection {
|
||||||
|
pub original_location: Option<Address>,
|
||||||
|
pub original_sheet: Option<u32>,
|
||||||
|
pub sheet: Option<u32>,
|
||||||
|
pub start: Option<Address>,
|
||||||
|
pub end: Option<Address>,
|
||||||
|
}
|
||||||
|
|
||||||
|
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)]
|
#[derive(Debug)]
|
||||||
@ -38,6 +72,7 @@ pub struct AppState<'ws> {
|
|||||||
pub viewport_state: ViewportState,
|
pub viewport_state: ViewportState,
|
||||||
pub command_state: TextState<'ws>,
|
pub command_state: TextState<'ws>,
|
||||||
pub numeric_prefix: Vec<char>,
|
pub numeric_prefix: Vec<char>,
|
||||||
|
pub range_select: RangeSelection,
|
||||||
dirty: bool,
|
dirty: bool,
|
||||||
popup: Vec<String>,
|
popup: Vec<String>,
|
||||||
}
|
}
|
||||||
@ -49,6 +84,7 @@ impl<'ws> Default for AppState<'ws> {
|
|||||||
viewport_state: Default::default(),
|
viewport_state: Default::default(),
|
||||||
command_state: Default::default(),
|
command_state: Default::default(),
|
||||||
numeric_prefix: Default::default(),
|
numeric_prefix: Default::default(),
|
||||||
|
range_select: Default::default(),
|
||||||
dirty: Default::default(),
|
dirty: Default::default(),
|
||||||
popup: Default::default(),
|
popup: Default::default(),
|
||||||
}
|
}
|
||||||
@ -97,6 +133,19 @@ impl Address {
|
|||||||
pub fn new(row: usize, col: usize) -> Self {
|
pub fn new(row: usize, col: usize) -> Self {
|
||||||
Self { row, col }
|
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 {
|
impl Default for Address {
|
||||||
@ -149,6 +198,26 @@ impl<'ws> Workspace<'ws> {
|
|||||||
Ok(())
|
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.
|
/// Move a row down in the current sheet.
|
||||||
pub fn move_down(&mut self) -> Result<()> {
|
pub fn move_down(&mut self) -> Result<()> {
|
||||||
let mut loc = self.book.location.clone();
|
let mut loc = self.book.location.clone();
|
||||||
@ -197,6 +266,7 @@ impl<'ws> Workspace<'ws> {
|
|||||||
Modality::CellEdit => self.handle_edit_input(key)?,
|
Modality::CellEdit => self.handle_edit_input(key)?,
|
||||||
Modality::Command => self.handle_command_input(key)?,
|
Modality::Command => self.handle_command_input(key)?,
|
||||||
Modality::Dialog => self.handle_dialog_input(key)?,
|
Modality::Dialog => self.handle_dialog_input(key)?,
|
||||||
|
Modality::RangeSelect => self.handle_range_select_input(key)?,
|
||||||
};
|
};
|
||||||
return Ok(result);
|
return Ok(result);
|
||||||
}
|
}
|
||||||
@ -217,13 +287,14 @@ impl<'ws> Workspace<'ws> {
|
|||||||
"* CTRl-h: Shrink column width by 1".to_string(),
|
"* CTRl-h: Shrink column width by 1".to_string(),
|
||||||
"* CTRl-n: Next sheet. Starts over at beginning if at end.".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-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(),
|
"* q exit".to_string(),
|
||||||
"* Ctrl-S Save sheet".to_string(),
|
"* Ctrl-S Save sheet".to_string(),
|
||||||
],
|
],
|
||||||
Modality::CellEdit => vec![
|
Modality::CellEdit => vec![
|
||||||
"Edit Mode:".to_string(),
|
"Edit Mode:".to_string(),
|
||||||
"* ENTER/RETURN: Exit edit mode and save changes".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(),
|
"* ESC: Exit edit mode and discard changes".to_string(),
|
||||||
"Otherwise edit as normal".to_string(),
|
"Otherwise edit as normal".to_string(),
|
||||||
],
|
],
|
||||||
@ -233,6 +304,14 @@ impl<'ws> Workspace<'ws> {
|
|||||||
"* CTRL-?: Exit command mode".to_string(),
|
"* CTRL-?: Exit command mode".to_string(),
|
||||||
"* ENTER/RETURN: run command and 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()],
|
_ => vec!["General help".to_string()],
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -276,6 +355,17 @@ impl<'ws> Workspace<'ws> {
|
|||||||
self.enter_dialog_mode(self.render_help_text());
|
self.enter_dialog_mode(self.render_help_text());
|
||||||
return Ok(None);
|
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::Enter => self.exit_edit_mode(true)?,
|
||||||
KeyCode::Esc => self.exit_edit_mode(false)?,
|
KeyCode::Esc => self.exit_edit_mode(false)?,
|
||||||
_ => {
|
_ => {
|
||||||
@ -363,6 +453,91 @@ impl<'ws> Workspace<'ws> {
|
|||||||
self.state.numeric_prefix.push(digit);
|
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 => {
|
||||||
|
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<Option<ExitCode>> {
|
fn handle_navigation_input(&mut self, key: event::KeyEvent) -> Result<Option<ExitCode>> {
|
||||||
if key.kind == KeyEventKind::Press {
|
if key.kind == KeyEventKind::Press {
|
||||||
match key.code {
|
match key.code {
|
||||||
@ -381,6 +556,9 @@ impl<'ws> Workspace<'ws> {
|
|||||||
KeyCode::Char('s') if key.modifiers == KeyModifiers::CONTROL => {
|
KeyCode::Char('s') if key.modifiers == KeyModifiers::CONTROL => {
|
||||||
self.save_file()?;
|
self.save_file()?;
|
||||||
}
|
}
|
||||||
|
KeyCode::Char('r') if key.modifiers == KeyModifiers::CONTROL => {
|
||||||
|
self.enter_range_select_mode();
|
||||||
|
}
|
||||||
KeyCode::Char('h') if key.modifiers == KeyModifiers::ALT => {
|
KeyCode::Char('h') if key.modifiers == KeyModifiers::ALT => {
|
||||||
self.enter_dialog_mode(self.render_help_text());
|
self.enter_dialog_mode(self.render_help_text());
|
||||||
}
|
}
|
||||||
@ -487,7 +665,10 @@ impl<'ws> Workspace<'ws> {
|
|||||||
return Ok(None);
|
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() {
|
for _ in 1..=self.state.get_n_prefix() {
|
||||||
action(self)?;
|
action(self)?;
|
||||||
}
|
}
|
||||||
@ -495,10 +676,6 @@ impl<'ws> Workspace<'ws> {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn enter_navigation_mode(&mut self) {
|
|
||||||
self.state.modality_stack.push(Modality::Navigate);
|
|
||||||
}
|
|
||||||
|
|
||||||
fn enter_command_mode(&mut self) {
|
fn enter_command_mode(&mut self) {
|
||||||
self.state.modality_stack.push(Modality::Command);
|
self.state.modality_stack.push(Modality::Command);
|
||||||
self.state.command_state.truncate();
|
self.state.command_state.truncate();
|
||||||
@ -511,6 +688,15 @@ impl<'ws> Workspace<'ws> {
|
|||||||
self.state.modality_stack.push(Modality::Dialog);
|
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) {
|
fn enter_edit_mode(&mut self) {
|
||||||
self.state.modality_stack.push(Modality::CellEdit);
|
self.state.modality_stack.push(Modality::CellEdit);
|
||||||
self.text_area
|
self.text_area
|
||||||
@ -535,6 +721,30 @@ impl<'ws> Workspace<'ws> {
|
|||||||
Ok(())
|
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<()> {
|
fn exit_edit_mode(&mut self, keep: bool) -> Result<()> {
|
||||||
self.text_area.set_cursor_line_style(Style::default());
|
self.text_area.set_cursor_line_style(Style::default());
|
||||||
self.text_area.set_cursor_style(Style::default());
|
self.text_area.set_cursor_style(Style::default());
|
||||||
@ -542,11 +752,10 @@ impl<'ws> Workspace<'ws> {
|
|||||||
if self.state.dirty && keep {
|
if self.state.dirty && keep {
|
||||||
self.book.edit_current_cell(contents)?;
|
self.book.edit_current_cell(contents)?;
|
||||||
self.book.evaluate();
|
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.dirty = false;
|
||||||
self.enter_navigation_mode();
|
self.state.pop_modality();
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -44,7 +44,7 @@ impl<'ws> Workspace<'ws> {
|
|||||||
Box::new(move |rect: Rect, buf: &mut Buffer, ws: &mut Self| {
|
Box::new(move |rect: Rect, buf: &mut Buffer, ws: &mut Self| {
|
||||||
let sheet_name = ws.book.get_sheet_name().unwrap_or("Unknown");
|
let sheet_name = ws.book.get_sheet_name().unwrap_or("Unknown");
|
||||||
let table_block = Block::bordered().title_top(sheet_name);
|
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())
|
.with_selected(ws.book.location.clone())
|
||||||
.block(table_block);
|
.block(table_block);
|
||||||
StatefulWidget::render(viewport, rect, buf, &mut ws.state.viewport_state);
|
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::CellEdit => "edit",
|
||||||
Modality::Command => "command",
|
Modality::Command => "command",
|
||||||
Modality::Dialog => "",
|
Modality::Dialog => "",
|
||||||
|
Modality::RangeSelect => "range-copy",
|
||||||
})
|
})
|
||||||
.title_bottom(
|
.title_bottom(
|
||||||
Line::from(format!(
|
Line::from(format!(
|
||||||
|
@ -1,15 +1,23 @@
|
|||||||
use ironcalc::base::Model;
|
use ironcalc::base::Model;
|
||||||
|
|
||||||
|
use crate::ui::AppState;
|
||||||
|
|
||||||
use super::{Address, Book, Viewport, ViewportState};
|
use super::{Address, Book, Viewport, ViewportState};
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_viewport_get_visible_columns() {
|
fn test_viewport_get_visible_columns() {
|
||||||
let mut state = ViewportState::default();
|
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 default_size = book.get_col_size(1).expect("Failed to get column size");
|
||||||
let width = dbg!(dbg!(default_size) * 12 / 2);
|
let width = dbg!(dbg!(default_size) * 12 / 2);
|
||||||
let viewport = Viewport::new(&book).with_selected(Address { row: 1, col: 17 });
|
let app_state = AppState::default();
|
||||||
let cols = viewport.get_visible_columns((width + 5) as u16, &mut state).expect("Failed to get visible columns");
|
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!(5, cols.len());
|
||||||
assert_eq!(17, cols.last().expect("Failed to get last column").idx);
|
assert_eq!(17, cols.last().expect("Failed to get last column").idx);
|
||||||
}
|
}
|
||||||
@ -17,32 +25,50 @@ fn test_viewport_get_visible_columns() {
|
|||||||
#[test]
|
#[test]
|
||||||
fn test_viewport_get_visible_rows() {
|
fn test_viewport_get_visible_rows() {
|
||||||
let mut state = dbg!(ViewportState::default());
|
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 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));
|
let rows = dbg!(viewport.get_visible_rows(height as u16, &mut state));
|
||||||
assert_eq!(height - 1, rows.len());
|
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"));
|
assert_eq!(17, *rows.last().expect("Failed to get last row"));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_viewport_visible_columns_after_length_change() {
|
fn test_viewport_visible_columns_after_length_change() {
|
||||||
let mut state = ViewportState::default();
|
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 default_size = book.get_col_size(1).expect("Failed to get column size");
|
||||||
let width = dbg!(dbg!(default_size) * 12 / 2);
|
let width = dbg!(dbg!(default_size) * 12 / 2);
|
||||||
{
|
{
|
||||||
let viewport = Viewport::new(&book).with_selected(Address { row: 1, col: 17 });
|
let app_state = AppState::default();
|
||||||
let cols = viewport.get_visible_columns((width + 5) as u16, &mut state).expect("Failed to get visible columns");
|
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!(5, cols.len());
|
||||||
assert_eq!(17, cols.last().expect("Failed to get last column").idx);
|
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 app_state = AppState::default();
|
||||||
let cols = viewport.get_visible_columns((width + 5) as u16, &mut state).expect("Failed to get visible columns");
|
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.len());
|
||||||
assert_eq!(1, cols.last().expect("Failed to get last column").idx);
|
assert_eq!(1, cols.last().expect("Failed to get last column").idx);
|
||||||
}
|
}
|
||||||
|
@ -7,7 +7,7 @@ use ratatui::{
|
|||||||
widgets::{Block, Cell, Row, StatefulWidget, Table, Widget},
|
widgets::{Block, Cell, Row, StatefulWidget, Table, Widget},
|
||||||
};
|
};
|
||||||
|
|
||||||
use super::{Address, Book};
|
use super::{Address, Book, RangeSelection};
|
||||||
|
|
||||||
// TODO(zaphar): Move this to the book module.
|
// TODO(zaphar): Move this to the book module.
|
||||||
// NOTE(zaphar): This is stolen from ironcalc but ironcalc doesn't expose it
|
// 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.
|
/// A renderable viewport over a book.
|
||||||
pub struct Viewport<'book> {
|
pub struct Viewport<'ws> {
|
||||||
pub(crate) selected: Address,
|
pub(crate) selected: Address,
|
||||||
book: &'book Book,
|
book: &'ws Book,
|
||||||
block: Option<Block<'book>>,
|
range_selection: &'ws RangeSelection,
|
||||||
|
block: Option<Block<'ws>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) const COLNAMES: [&'static str; 26] = [
|
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",
|
"T", "U", "V", "W", "X", "Y", "Z",
|
||||||
];
|
];
|
||||||
|
|
||||||
impl<'book> Viewport<'book> {
|
impl<'ws> Viewport<'ws> {
|
||||||
pub fn new(book: &'book Book) -> Self {
|
pub fn new(book: &'ws Book, app_state: &'ws RangeSelection) -> Self {
|
||||||
Self {
|
Self {
|
||||||
book,
|
book,
|
||||||
|
range_selection: app_state,
|
||||||
selected: Default::default(),
|
selected: Default::default(),
|
||||||
block: None,
|
block: None,
|
||||||
}
|
}
|
||||||
@ -127,7 +129,7 @@ impl<'book> Viewport<'book> {
|
|||||||
return Ok(visible);
|
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.block = Some(block);
|
||||||
self
|
self
|
||||||
}
|
}
|
||||||
@ -158,7 +160,17 @@ impl<'book> Viewport<'book> {
|
|||||||
.book
|
.book
|
||||||
.get_cell_addr_rendered(&Address { row: ri, col: *ci })
|
.get_cell_addr_rendered(&Address { row: ri, col: *ci })
|
||||||
.unwrap();
|
.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) {
|
match (self.book.location.row == ri, self.book.location.col == *ci) {
|
||||||
(true, true) => cell.fg(Color::White).bg(Color::Rgb(57, 61, 71)),
|
(true, true) => cell.fg(Color::White).bg(Color::Rgb(57, 61, 71)),
|
||||||
_ => cell,
|
_ => cell,
|
||||||
@ -197,7 +209,7 @@ impl<'book> Viewport<'book> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl<'book> StatefulWidget for Viewport<'book> {
|
impl<'ws> StatefulWidget for Viewport<'ws> {
|
||||||
type State = ViewportState;
|
type State = ViewportState;
|
||||||
|
|
||||||
fn render(self, area: Rect, buf: &mut Buffer, state: &mut Self::State) {
|
fn render(self, area: Rect, buf: &mut Buffer, state: &mut Self::State) {
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
use crossterm::event::{Event, KeyCode, KeyEvent, KeyModifiers};
|
use crossterm::event::{Event, KeyCode, KeyEvent, KeyModifiers};
|
||||||
|
|
||||||
use crate::ui::Modality;
|
use crate::ui::{Address, Modality};
|
||||||
|
|
||||||
use super::cmd::{parse, Cmd};
|
use super::cmd::{parse, Cmd};
|
||||||
use super::Workspace;
|
use super::Workspace;
|
||||||
@ -351,3 +351,77 @@ fn test_navigation_tab_next_numeric_prefix()
|
|||||||
.expect("Failed to handle 'Ctrl-n' key event");
|
.expect("Failed to handle 'Ctrl-n' key event");
|
||||||
assert_eq!("Sheet1", ws.book.get_sheet_name().expect("Failed to get sheet name"));
|
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());
|
||||||
|
}
|
||||||
|
Loading…
x
Reference in New Issue
Block a user