mirror of
https://github.com/zaphar/sheetsui.git
synced 2025-07-23 05:19:48 -04:00
Compare commits
10 Commits
4ae7f357c1
...
80b17c1cb6
Author | SHA1 | Date | |
---|---|---|---|
80b17c1cb6 | |||
8e7895cdbc | |||
240149caba | |||
2bd4b1ff83 | |||
57b1681933 | |||
76b4d46222 | |||
fa5c2c2d58 | |||
65b0fc4bba | |||
d09e8d4902 | |||
d93f0ec90c |
1
.gitignore
vendored
1
.gitignore
vendored
@ -1,3 +1,4 @@
|
||||
/target
|
||||
result/
|
||||
*.json
|
||||
tarpaulin-report.*
|
||||
|
23
Makefile
Normal file
23
Makefile
Normal file
@ -0,0 +1,23 @@
|
||||
rwildcard=$(foreach d,$(wildcard $(1:=/*)),$(call rwildcard,$d,$2) $(filter $(subst *,%,$2),$d))
|
||||
rust_files=$(call rwildcard,src,*.rs)
|
||||
|
||||
test: $(rust-files)
|
||||
cargo test
|
||||
|
||||
build: $(rust-files)
|
||||
cargo build
|
||||
|
||||
tarpaulin-report.%: $(rust_files)
|
||||
cargo tarpaulin --skip-clean --test --out $*
|
||||
|
||||
cover: tarpaulin-report.html
|
||||
|
||||
view-cover: tarpaulin-report.html
|
||||
open $<
|
||||
|
||||
clean-tarpaulin:
|
||||
rm -f tarpaulin-report.*
|
||||
|
||||
clean: clean-tarpaulin
|
||||
cargo clean
|
||||
|
@ -44,6 +44,8 @@ table and between the sheets using the following keybinds:
|
||||
* `l` and, ➡️ will move one cell to the right.
|
||||
* `j`, ⬇️, and `Enter` will move one cell down.
|
||||
* `k` ⬆️, will move one cell up.
|
||||
* `d` will delete the contents of the selected cell leaving style untouched
|
||||
* `D` will delete the contents of the selected cell including any style
|
||||
|
||||
**Sheet Navigation**
|
||||
|
||||
@ -66,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,12 +135,16 @@ 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
|
||||
Range Select mode copies a range reference for use later or delete a range's contents. 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.
|
||||
* `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
|
||||
|
||||
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.
|
||||
|
Binary file not shown.
Binary file not shown.
@ -37,7 +37,7 @@
|
||||
rust-bin = pkgs.rust-bin;
|
||||
|
||||
devShells.default = pkgs.mkShell {
|
||||
nativeBuildInputs = with pkgs; [ gnumake my-rust-bin rust-analyzer ];
|
||||
nativeBuildInputs = with pkgs; [ gnumake my-rust-bin rust-analyzer cargo-tarpaulin ];
|
||||
};
|
||||
});
|
||||
}
|
||||
|
151
src/book/mod.rs
151
src/book/mod.rs
@ -18,6 +18,64 @@ mod test;
|
||||
|
||||
const COL_PIXELS: f64 = 5.0;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct AddressRange<'book> {
|
||||
pub start: &'book Address,
|
||||
pub end: &'book Address,
|
||||
}
|
||||
|
||||
impl<'book> AddressRange<'book> {
|
||||
pub fn as_rows(&self) -> Vec<Vec<Address>> {
|
||||
let (row_range, col_range) = self.get_ranges();
|
||||
let mut rows = Vec::with_capacity(row_range.len());
|
||||
for ri in row_range.iter() {
|
||||
let mut row = Vec::with_capacity(col_range.len());
|
||||
for ci in col_range.iter() {
|
||||
row.push(Address { row: *ri, col: *ci });
|
||||
}
|
||||
rows.push(row);
|
||||
}
|
||||
rows
|
||||
}
|
||||
|
||||
pub fn as_series(&self) -> Vec<Address> {
|
||||
let (row_range, col_range) = self.get_ranges();
|
||||
let mut rows = Vec::with_capacity(row_range.len() * col_range.len());
|
||||
for ri in row_range.iter() {
|
||||
for ci in col_range.iter() {
|
||||
rows.push(Address { row: *ri, col: *ci });
|
||||
}
|
||||
}
|
||||
rows
|
||||
}
|
||||
|
||||
fn get_ranges(&self) -> (Vec<usize>, Vec<usize>) {
|
||||
let row_range = if self.start.row <= self.end.row {
|
||||
(self.start.row..=self.end.row)
|
||||
.into_iter()
|
||||
.collect::<Vec<usize>>()
|
||||
} else {
|
||||
let mut v = (self.start.row..=self.end.row)
|
||||
.into_iter()
|
||||
.collect::<Vec<usize>>();
|
||||
v.reverse();
|
||||
v
|
||||
};
|
||||
let col_range = if self.start.col <= self.end.col {
|
||||
(self.start.col..=self.end.col)
|
||||
.into_iter()
|
||||
.collect::<Vec<usize>>()
|
||||
} else {
|
||||
let mut v = (self.start.col..=self.end.col)
|
||||
.into_iter()
|
||||
.collect::<Vec<usize>>();
|
||||
v.reverse();
|
||||
v
|
||||
};
|
||||
(row_range, col_range)
|
||||
}
|
||||
}
|
||||
|
||||
/// A spreadsheet book with some internal state tracking.
|
||||
pub struct Book {
|
||||
pub(crate) model: Model,
|
||||
@ -96,7 +154,7 @@ 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;
|
||||
@ -104,6 +162,79 @@ impl Book {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Extend a cell to the rest of the range.
|
||||
pub fn extend_to(&mut self, from: &Address, to: &Address) -> Result<()> {
|
||||
for cell in (AddressRange {
|
||||
start: from,
|
||||
end: to,
|
||||
})
|
||||
.as_series()
|
||||
.iter()
|
||||
.skip(1)
|
||||
{
|
||||
let contents = self
|
||||
.model
|
||||
.extend_to(
|
||||
self.current_sheet,
|
||||
from.row as i32,
|
||||
from.col as i32,
|
||||
cell.row as i32,
|
||||
cell.col as i32,
|
||||
)
|
||||
.map_err(|e| anyhow!(e))?;
|
||||
self.model
|
||||
.set_user_input(
|
||||
self.current_sheet,
|
||||
cell.row as i32,
|
||||
cell.col 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())
|
||||
}
|
||||
|
||||
pub fn clear_current_cell_all(&mut self) -> Result<()> {
|
||||
self.clear_cell_all(self.current_sheet as u32, self.location.clone())
|
||||
}
|
||||
|
||||
pub fn clear_cell_contents(&mut self, sheet: u32, Address { row, col }: Address) -> Result<()> {
|
||||
Ok(self
|
||||
.model
|
||||
.cell_clear_contents(sheet, row as i32, col as i32)
|
||||
.map_err(|s| anyhow!("Unable to clear cell contents {}", s))?)
|
||||
}
|
||||
|
||||
pub fn clear_cell_range(&mut self, sheet: u32, start: Address, end: Address) -> Result<()> {
|
||||
for row in start.row..=end.row {
|
||||
for col in start.col..=end.col {
|
||||
self.clear_cell_contents(sheet, Address { row, col })?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn clear_cell_all(&mut self, sheet: u32, Address { row, col }: Address) -> Result<()> {
|
||||
Ok(self
|
||||
.model
|
||||
.cell_clear_all(sheet, row as i32, col as i32)
|
||||
.map_err(|s| anyhow!("Unable to clear cell contents {}", s))?)
|
||||
}
|
||||
|
||||
pub fn clear_cell_range_all(&mut self, sheet: u32, start: Address, end: Address) -> Result<()> {
|
||||
for row in start.row..=end.row {
|
||||
for col in start.col..=end.col {
|
||||
self.clear_cell_all(sheet, Address { row, col })?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Get a cells formatted content.
|
||||
pub fn get_current_cell_rendered(&self) -> Result<String> {
|
||||
Ok(self.get_cell_addr_rendered(&self.location)?)
|
||||
@ -117,6 +248,14 @@ impl Book {
|
||||
.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> {
|
||||
Ok(self
|
||||
@ -132,13 +271,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,
|
||||
@ -253,7 +392,6 @@ impl Book {
|
||||
self.current_sheet = next;
|
||||
}
|
||||
|
||||
|
||||
/// Select a sheet by id.
|
||||
pub fn select_sheet_by_id(&mut self, id: u32) -> bool {
|
||||
if let Some((idx, _sheet)) = self
|
||||
@ -292,7 +430,8 @@ impl Book {
|
||||
.model
|
||||
.workbook
|
||||
.worksheet(idx as u32)
|
||||
.map_err(|s| anyhow!("Invalid Worksheet: {}", s))?.name)
|
||||
.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
|
||||
@ -307,7 +446,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
|
||||
}
|
||||
}
|
||||
|
@ -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"));
|
||||
|
@ -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() {
|
||||
|
226
src/ui/mod.rs
226
src/ui/mod.rs
@ -1,7 +1,7 @@
|
||||
//! Ui rendering logic
|
||||
use std::{path::PathBuf, process::ExitCode};
|
||||
|
||||
use crate::book::Book;
|
||||
use crate::book::{AddressRange, Book};
|
||||
|
||||
use anyhow::{anyhow, Result};
|
||||
use crossterm::event::{self, Event, KeyCode, KeyEventKind, KeyModifiers};
|
||||
@ -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(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -201,7 +211,11 @@ impl<'ws> Workspace<'ws> {
|
||||
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()));
|
||||
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!(
|
||||
@ -215,7 +229,7 @@ impl<'ws> Workspace<'ws> {
|
||||
}
|
||||
return a1;
|
||||
}
|
||||
return String::new()
|
||||
return String::new();
|
||||
}
|
||||
|
||||
/// Move a row down in the current sheet.
|
||||
@ -228,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();
|
||||
@ -281,6 +301,8 @@ impl<'ws> Workspace<'ws> {
|
||||
"* ENTER/RETURN: Go down one cell".to_string(),
|
||||
"* TAB: Go over one cell".to_string(),
|
||||
"* h,j,k,l: vim style navigation".to_string(),
|
||||
"* d: clear cell contents leaving style untouched".to_string(),
|
||||
"* D: clear cell contents including style".to_string(),
|
||||
"* CTRl-r: Add a row".to_string(),
|
||||
"* CTRl-c: Add a column".to_string(),
|
||||
"* CTRl-l: Grow column width by 1".to_string(),
|
||||
@ -295,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(),
|
||||
],
|
||||
@ -308,6 +331,8 @@ impl<'ws> Workspace<'ws> {
|
||||
"Range Selection Mode:".to_string(),
|
||||
"* ESC: Exit command mode".to_string(),
|
||||
"* h,j,k,l: vim style navigation".to_string(),
|
||||
"* d: delete the contents of the range leaving style untouched".to_string(),
|
||||
"* D: clear cell contents including style".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(),
|
||||
@ -356,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 => {
|
||||
@ -409,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)
|
||||
@ -471,6 +496,30 @@ impl<'ws> Workspace<'ws> {
|
||||
KeyCode::Char(d) if d.is_ascii_digit() => {
|
||||
self.handle_numeric_prefix(d);
|
||||
}
|
||||
KeyCode::Char('D') => {
|
||||
if let Some((start, end)) = self.state.range_select.get_range() {
|
||||
self.book.clear_cell_range_all(
|
||||
self.state
|
||||
.range_select
|
||||
.sheet
|
||||
.unwrap_or_else(|| self.book.current_sheet),
|
||||
start,
|
||||
end,
|
||||
)?;
|
||||
}
|
||||
}
|
||||
KeyCode::Char('d') => {
|
||||
if let Some((start, end)) = self.state.range_select.get_range() {
|
||||
self.book.clear_cell_range(
|
||||
self.state
|
||||
.range_select
|
||||
.sheet
|
||||
.unwrap_or_else(|| self.book.current_sheet),
|
||||
start,
|
||||
end,
|
||||
)?;
|
||||
}
|
||||
}
|
||||
KeyCode::Char('h') => {
|
||||
self.run_with_prefix(|ws: &mut Workspace<'_>| -> Result<()> {
|
||||
ws.move_left()?;
|
||||
@ -500,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();
|
||||
@ -523,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
|
||||
}
|
||||
@ -531,6 +594,53 @@ 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((
|
||||
start,
|
||||
end,
|
||||
)) => {
|
||||
let mut rows = Vec::new();
|
||||
for row in (AddressRange { start, end, }).as_rows() {
|
||||
let mut cols = Vec::new();
|
||||
for cell in row {
|
||||
cols.push(if formatted {
|
||||
self.book
|
||||
.get_cell_addr_rendered(&cell)?
|
||||
} else {
|
||||
self.book
|
||||
.get_cell_addr_contents(&cell)?
|
||||
});
|
||||
}
|
||||
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());
|
||||
@ -555,8 +665,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());
|
||||
@ -567,6 +715,12 @@ impl<'ws> Workspace<'ws> {
|
||||
Ok(())
|
||||
})?;
|
||||
}
|
||||
KeyCode::Char('d') => {
|
||||
self.book.clear_current_cell()?;
|
||||
}
|
||||
KeyCode::Char('D') => {
|
||||
self.book.clear_current_cell_all()?;
|
||||
}
|
||||
KeyCode::Char('p') if key.modifiers == KeyModifiers::CONTROL => {
|
||||
self.run_with_prefix(|ws: &mut Workspace<'_>| -> Result<()> {
|
||||
ws.book.select_prev_sheet();
|
||||
@ -656,14 +810,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>,
|
||||
@ -687,11 +883,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());
|
||||
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);
|
||||
}
|
||||
|
@ -28,23 +28,38 @@ 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>>())
|
||||
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)
|
||||
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);
|
||||
|
@ -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");
|
||||
|
@ -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,
|
||||
@ -160,7 +160,9 @@ impl<'ws> Viewport<'ws> {
|
||||
.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
|
||||
|
@ -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 });
|
||||
}
|
||||
|
Loading…
x
Reference in New Issue
Block a user