diff --git a/Makefile b/Makefile index 9344de3..503248f 100644 --- a/Makefile +++ b/Makefile @@ -1,6 +1,7 @@ lint: cargo fmt -- --check cargo clippy --all-targets --all-features + cd webapp && npm install && npm run check format: cargo fmt @@ -10,7 +11,10 @@ tests: lint ./target/debug/documentation cmp functions.md wiki/functions.md || exit 1 make remove-artifacts - cd bindings/wasm/ && wasm-pack build --target nodejs && node tests/test.mjs + # Regretabbly we need to build the wasm twice, once for the nodejs tests + # and a second one for the vitest. + cd bindings/wasm/ && wasm-pack build --target nodejs && node tests/test.mjs && make + cd webapp && npm run test remove-artifacts: rm -f xlsx/hello-calc.xlsx diff --git a/base/src/constants.rs b/base/src/constants.rs index f1f7348..ef58273 100644 --- a/base/src/constants.rs +++ b/base/src/constants.rs @@ -6,6 +6,8 @@ pub(crate) const DEFAULT_COLUMN_WIDTH: f64 = 100.0; pub(crate) const DEFAULT_ROW_HEIGHT: f64 = 21.0; pub(crate) const COLUMN_WIDTH_FACTOR: f64 = 12.0; pub(crate) const ROW_HEIGHT_FACTOR: f64 = 2.0; +pub(crate) const DEFAULT_WINDOW_HEIGH: i64 = 600; +pub(crate) const DEFAULT_WINDOW_WIDTH: i64 = 800; pub(crate) const LAST_COLUMN: i32 = 16_384; pub(crate) const LAST_ROW: i32 = 1_048_576; diff --git a/base/src/lib.rs b/base/src/lib.rs index a5c632b..8c0e45d 100644 --- a/base/src/lib.rs +++ b/base/src/lib.rs @@ -57,4 +57,5 @@ pub mod mock_time; pub use model::get_milliseconds_since_epoch; pub use model::Model; +pub use user_model::BorderArea; pub use user_model::UserModel; diff --git a/base/src/new_empty.rs b/base/src/new_empty.rs index 3b01cc4..fc4ba11 100644 --- a/base/src/new_empty.rs +++ b/base/src/new_empty.rs @@ -4,6 +4,7 @@ use std::collections::HashMap; use crate::{ calc_result::Range, + constants::{DEFAULT_WINDOW_HEIGH, DEFAULT_WINDOW_WIDTH}, expressions::{ lexer::LexerMode, parser::{ @@ -353,7 +354,14 @@ impl Model { let now = dt.format("%Y-%m-%dT%H:%M:%SZ").to_string(); let mut views = HashMap::new(); - views.insert(0, WorkbookView { sheet: 0 }); + views.insert( + 0, + WorkbookView { + sheet: 0, + window_width: DEFAULT_WINDOW_WIDTH, + window_height: DEFAULT_WINDOW_HEIGH, + }, + ); // String versions of the locale are added here to simplify the serialize/deserialize logic let workbook = Workbook { diff --git a/base/src/test/user_model/mod.rs b/base/src/test/user_model/mod.rs index 1259726..0bb632c 100644 --- a/base/src/test/user_model/mod.rs +++ b/base/src/test/user_model/mod.rs @@ -1,14 +1,20 @@ mod test_add_delete_sheets; mod test_autofill_columns; mod test_autofill_rows; +mod test_border; mod test_clear_cells; mod test_diff_queue; mod test_evaluation; mod test_general; mod test_grid_lines; +mod test_keyboard_navigation; +mod test_on_area_selection; +mod test_on_expand_selected_range; +mod test_on_paste_styles; mod test_rename_sheet; mod test_row_column; mod test_styles; mod test_to_from_bytes; mod test_undo_redo; mod test_view; +mod test_window_size; diff --git a/base/src/test/user_model/test_add_delete_sheets.rs b/base/src/test/user_model/test_add_delete_sheets.rs index 72ae562..cc07536 100644 --- a/base/src/test/user_model/test_add_delete_sheets.rs +++ b/base/src/test/user_model/test_add_delete_sheets.rs @@ -82,3 +82,15 @@ fn delete_sheet_propagates() { let sheets_info = model2.get_worksheets_properties(); assert_eq!(sheets_info.len(), 1); } + +#[test] +fn delete_last_sheet() { + // Deleting the last sheet, selects the previous + let mut model = UserModel::new_empty("model", "en", "UTC").unwrap(); + model.new_sheet(); + model.new_sheet(); + model.set_selected_sheet(2).unwrap(); + model.delete_sheet(2).unwrap(); + + assert_eq!(model.get_selected_sheet(), 1); +} diff --git a/base/src/test/user_model/test_border.rs b/base/src/test/user_model/test_border.rs new file mode 100644 index 0000000..7437ce6 --- /dev/null +++ b/base/src/test/user_model/test_border.rs @@ -0,0 +1,416 @@ +#![allow(clippy::unwrap_used)] + +use crate::{ + expressions::{types::Area, utils::number_to_column}, + types::{Border, BorderItem, BorderStyle}, + BorderArea, UserModel, +}; + +#[test] +fn borders_all() { + let mut model = UserModel::new_empty("model", "en", "UTC").unwrap(); + // We set an outer border in cells F5:H9 + let range = &Area { + sheet: 0, + row: 5, + column: 6, + width: 3, + height: 4, + }; + assert_eq!(number_to_column(6).unwrap(), "F"); + assert_eq!(number_to_column(8).unwrap(), "H"); + // ATM we don't have a way to create the object from Rust, that's ok. + let border_area: BorderArea = serde_json::from_str( + r##"{ + "item": { + "style": "thin", + "color": "#FF5566" + }, + "type": "All" + }"##, + ) + .unwrap(); + model.set_area_with_border(range, &border_area).unwrap(); + for row in 5..9 { + for column in 6..9 { + let style = model.get_cell_style(0, row, column).unwrap(); + let border_item = BorderItem { + style: BorderStyle::Thin, + color: Some("#FF5566".to_string()), + }; + let expected_border = Border { + diagonal_up: false, + diagonal_down: false, + left: Some(border_item.clone()), + right: Some(border_item.clone()), + top: Some(border_item.clone()), + bottom: Some(border_item.clone()), + diagonal: None, + }; + assert_eq!(style.border, expected_border); + } + } + + // Lets remove all of them: + let border_area: BorderArea = serde_json::from_str( + r##"{ + "item": { + "style": "thin", + "color": "#FF5566" + }, + "type": "None" + }"##, + ) + .unwrap(); + model.set_area_with_border(range, &border_area).unwrap(); + for row in 5..9 { + for column in 6..9 { + let style = model.get_cell_style(0, row, column).unwrap(); + let expected_border = Border { + diagonal_up: false, + diagonal_down: false, + left: None, + right: None, + top: None, + bottom: None, + diagonal: None, + }; + assert_eq!(style.border, expected_border); + } + } +} + +#[test] +fn borders_inner() { + let mut model = UserModel::new_empty("model", "en", "UTC").unwrap(); + // We set an outer border in cells F5:H9 + let range = &Area { + sheet: 0, + row: 5, + column: 6, + width: 3, + height: 4, + }; + assert_eq!(number_to_column(6).unwrap(), "F"); + assert_eq!(number_to_column(8).unwrap(), "H"); + // ATM we don't have a way to create the object from Rust, that's ok. + let border_area: BorderArea = serde_json::from_str( + r##"{ + "item": { + "style": "thin", + "color": "#FF5566" + }, + "type": "Inner" + }"##, + ) + .unwrap(); + model.set_area_with_border(range, &border_area).unwrap(); + // The inner part all have borders + for row in 6..8 { + for column in 7..8 { + let style = model.get_cell_style(0, row, column).unwrap(); + let border_item = BorderItem { + style: BorderStyle::Thin, + color: Some("#FF5566".to_string()), + }; + let expected_border = Border { + diagonal_up: false, + diagonal_down: false, + left: Some(border_item.clone()), + right: Some(border_item.clone()), + top: Some(border_item.clone()), + bottom: Some(border_item.clone()), + diagonal: None, + }; + assert_eq!(style.border, expected_border); + } + } + // F5 has border only left and bottom + { + // We check the border on F5 + let style = model.get_cell_style(0, 5, 6).unwrap(); + let border_item = BorderItem { + style: BorderStyle::Thin, + color: Some("#FF5566".to_string()), + }; + // It should be right and bottom + let expected_border = Border { + diagonal_up: false, + diagonal_down: false, + left: None, + right: Some(border_item.clone()), + top: None, + bottom: Some(border_item), + diagonal: None, + }; + assert_eq!(style.border, expected_border); + } + { + // Then let's try the bottom-right border + let style = model.get_cell_style(0, 8, 8).unwrap(); + let border_item = BorderItem { + style: BorderStyle::Thin, + color: Some("#FF5566".to_string()), + }; + // It should be only left and top + let expected_border = Border { + diagonal_up: false, + diagonal_down: false, + left: Some(border_item.clone()), + right: None, + top: Some(border_item.clone()), + bottom: None, + diagonal: None, + }; + assert_eq!(style.border, expected_border); + } +} + +#[test] +fn borders_outer() { + let mut model = UserModel::new_empty("model", "en", "UTC").unwrap(); + // We set an outer border in cells F5:H9 + let range = &Area { + sheet: 0, + row: 5, + column: 6, + width: 3, + height: 4, + }; + assert_eq!(number_to_column(6).unwrap(), "F"); + assert_eq!(number_to_column(8).unwrap(), "H"); + // ATM we don't have a way to create the object from Rust, that's ok. + let border_area: BorderArea = serde_json::from_str( + r##"{ + "item": { + "style": "thin", + "color": "#FF5566" + }, + "type": "Outer" + }"##, + ) + .unwrap(); + model.set_area_with_border(range, &border_area).unwrap(); + { + // We check the border on F5 + let style = model.get_cell_style(0, 5, 6).unwrap(); + let border_item = BorderItem { + style: BorderStyle::Thin, + color: Some("#FF5566".to_string()), + }; + // It should be only left and top + let expected_border = Border { + diagonal_up: false, + diagonal_down: false, + left: Some(border_item.clone()), + right: None, + top: Some(border_item), + bottom: None, + diagonal: None, + }; + assert_eq!(style.border, expected_border); + } + { + // Then let's try the bottom-right border + let style = model.get_cell_style(0, 8, 8).unwrap(); + let border_item = BorderItem { + style: BorderStyle::Thin, + color: Some("#FF5566".to_string()), + }; + // It should be only left and top + let expected_border = Border { + diagonal_up: false, + diagonal_down: false, + left: None, + right: Some(border_item.clone()), + top: None, + bottom: Some(border_item.clone()), + diagonal: None, + }; + assert_eq!(style.border, expected_border); + } +} + +#[test] +fn borders_top() { + let mut model = UserModel::new_empty("model", "en", "UTC").unwrap(); + // We set an outer border in cells F5:H9 + let range = &Area { + sheet: 0, + row: 5, + column: 6, + width: 3, + height: 4, + }; + assert_eq!(number_to_column(6).unwrap(), "F"); + assert_eq!(number_to_column(8).unwrap(), "H"); + // ATM we don't have a way to create the object from Rust, that's ok. + let border_area: BorderArea = serde_json::from_str( + r##"{ + "item": { + "style": "thin", + "color": "#FF5566" + }, + "type": "Top" + }"##, + ) + .unwrap(); + model.set_area_with_border(range, &border_area).unwrap(); + for row in 5..9 { + for column in 6..9 { + let style = model.get_cell_style(0, row, column).unwrap(); + let border_item = BorderItem { + style: BorderStyle::Thin, + color: Some("#FF5566".to_string()), + }; + let expected_border = Border { + diagonal_up: false, + diagonal_down: false, + left: None, + right: None, + top: Some(border_item.clone()), + bottom: None, + diagonal: None, + }; + assert_eq!(style.border, expected_border); + } + } +} + +#[test] +fn borders_right() { + let mut model = UserModel::new_empty("model", "en", "UTC").unwrap(); + // We set an outer border in cells F5:H9 + let range = &Area { + sheet: 0, + row: 5, + column: 6, + width: 3, + height: 4, + }; + assert_eq!(number_to_column(6).unwrap(), "F"); + assert_eq!(number_to_column(8).unwrap(), "H"); + // ATM we don't have a way to create the object from Rust, that's ok. + let border_area: BorderArea = serde_json::from_str( + r##"{ + "item": { + "style": "thin", + "color": "#FF5566" + }, + "type": "Right" + }"##, + ) + .unwrap(); + model.set_area_with_border(range, &border_area).unwrap(); + for row in 5..9 { + for column in 6..9 { + let style = model.get_cell_style(0, row, column).unwrap(); + let border_item = BorderItem { + style: BorderStyle::Thin, + color: Some("#FF5566".to_string()), + }; + let expected_border = Border { + diagonal_up: false, + diagonal_down: false, + left: None, + right: Some(border_item.clone()), + top: None, + bottom: None, + diagonal: None, + }; + assert_eq!(style.border, expected_border); + } + } +} + +#[test] +fn borders_bottom() { + let mut model = UserModel::new_empty("model", "en", "UTC").unwrap(); + // We set an outer border in cells F5:H9 + let range = &Area { + sheet: 0, + row: 5, + column: 6, + width: 3, + height: 4, + }; + assert_eq!(number_to_column(6).unwrap(), "F"); + assert_eq!(number_to_column(8).unwrap(), "H"); + // ATM we don't have a way to create the object from Rust, that's ok. + let border_area: BorderArea = serde_json::from_str( + r##"{ + "item": { + "style": "thin", + "color": "#FF5566" + }, + "type": "Bottom" + }"##, + ) + .unwrap(); + model.set_area_with_border(range, &border_area).unwrap(); + for row in 5..9 { + for column in 6..9 { + let style = model.get_cell_style(0, row, column).unwrap(); + let border_item = BorderItem { + style: BorderStyle::Thin, + color: Some("#FF5566".to_string()), + }; + let expected_border = Border { + diagonal_up: false, + diagonal_down: false, + left: None, + right: None, + top: None, + bottom: Some(border_item.clone()), + diagonal: None, + }; + assert_eq!(style.border, expected_border); + } + } +} + +#[test] +fn borders_left() { + let mut model = UserModel::new_empty("model", "en", "UTC").unwrap(); + // We set an outer border in cells F5:H9 + let range = &Area { + sheet: 0, + row: 5, + column: 6, + width: 3, + height: 4, + }; + assert_eq!(number_to_column(6).unwrap(), "F"); + assert_eq!(number_to_column(8).unwrap(), "H"); + // ATM we don't have a way to create the object from Rust, that's ok. + let border_area: BorderArea = serde_json::from_str( + r##"{ + "item": { + "style": "thin", + "color": "#FF5566" + }, + "type": "Left" + }"##, + ) + .unwrap(); + model.set_area_with_border(range, &border_area).unwrap(); + for row in 5..9 { + for column in 6..9 { + let style = model.get_cell_style(0, row, column).unwrap(); + let border_item = BorderItem { + style: BorderStyle::Thin, + color: Some("#FF5566".to_string()), + }; + let expected_border = Border { + diagonal_up: false, + diagonal_down: false, + left: Some(border_item.clone()), + right: None, + top: None, + bottom: None, + diagonal: None, + }; + assert_eq!(style.border, expected_border); + } + } +} diff --git a/base/src/test/user_model/test_keyboard_navigation.rs b/base/src/test/user_model/test_keyboard_navigation.rs new file mode 100644 index 0000000..a61acae --- /dev/null +++ b/base/src/test/user_model/test_keyboard_navigation.rs @@ -0,0 +1,136 @@ +#![allow(clippy::unwrap_used)] + +use crate::{ + constants::{ + DEFAULT_COLUMN_WIDTH, DEFAULT_ROW_HEIGHT, DEFAULT_WINDOW_HEIGH, DEFAULT_WINDOW_WIDTH, + LAST_COLUMN, + }, + test::util::new_empty_model, + UserModel, +}; + +#[test] +fn basic_navigation() { + let model = new_empty_model(); + let mut model = UserModel::from_model(model); + model.on_arrow_right().unwrap(); + let view = model.get_selected_view(); + assert_eq!(view.range, [1, 2, 1, 2]); + assert_eq!(view.column, 2); + assert_eq!(view.row, 1); + + model.on_arrow_left().unwrap(); + let view = model.get_selected_view(); + assert_eq!(view.range, [1, 1, 1, 1]); + assert_eq!(view.column, 1); + assert_eq!(view.row, 1); + + model.on_arrow_left().unwrap(); + let view = model.get_selected_view(); + assert_eq!(view.range, [1, 1, 1, 1]); + assert_eq!(view.column, 1); + assert_eq!(view.row, 1); + + model.on_arrow_down().unwrap(); + let view = model.get_selected_view(); + assert_eq!(view.range, [2, 1, 2, 1]); + assert_eq!(view.column, 1); + assert_eq!(view.row, 2); + + model.on_arrow_up().unwrap(); + let view = model.get_selected_view(); + assert_eq!(view.range, [1, 1, 1, 1]); + assert_eq!(view.column, 1); + assert_eq!(view.row, 1); + + model.on_arrow_up().unwrap(); + let view = model.get_selected_view(); + assert_eq!(view.range, [1, 1, 1, 1]); + assert_eq!(view.column, 1); + assert_eq!(view.row, 1); +} + +#[test] +fn scroll_right() { + let window_width = DEFAULT_WINDOW_WIDTH as f64; + let column_width = DEFAULT_COLUMN_WIDTH; + let column_count = f64::floor(window_width / column_width) as i32; + + let model = new_empty_model(); + let mut model = UserModel::from_model(model); + model.on_arrow_right().unwrap(); + + model.set_selected_cell(3, column_count).unwrap(); + model.on_arrow_right().unwrap(); + let view = model.get_selected_view(); + assert_eq!(view.left_column, 2); + + model.on_arrow_right().unwrap(); + let view = model.get_selected_view(); + assert_eq!(view.left_column, 3); +} + +#[test] +fn last_colum() { + let model = new_empty_model(); + let mut model = UserModel::from_model(model); + model.set_selected_cell(3, LAST_COLUMN).unwrap(); + let view = model.get_selected_view(); + assert_eq!(view.column, LAST_COLUMN); + + model.on_arrow_right().unwrap(); + let view = model.get_selected_view(); + assert_eq!(view.column, LAST_COLUMN); +} + +#[test] +fn page_down() { + let model = new_empty_model(); + let mut model = UserModel::from_model(model); + let window_height = DEFAULT_WINDOW_HEIGH as f64; + let row_height = DEFAULT_ROW_HEIGHT; + let row_count = f64::floor(window_height / row_height) as i32; + model.on_page_down().unwrap(); + + let view = model.get_selected_view(); + assert_eq!(view.row, 1 + row_count); + let scroll_y = model.get_scroll_y().unwrap(); + assert_eq!(scroll_y, (row_count as f64) * DEFAULT_ROW_HEIGHT); +} + +// we just test that page up and page down are inverse operations +#[test] +fn page_up() { + let model = new_empty_model(); + let mut model = UserModel::from_model(model); + model.on_page_down().unwrap(); + let row1 = model.get_selected_view().row; + + model.on_page_down().unwrap(); + let row2 = model.get_selected_view().row; + + model.on_page_down().unwrap(); + let row3 = model.get_selected_view().row; + + model.on_page_down().unwrap(); + + model.on_page_up().unwrap(); + assert_eq!(model.get_selected_view().row, row3); + + model.on_page_up().unwrap(); + assert_eq!(model.get_selected_view().row, row2); + + model.on_page_up().unwrap(); + assert_eq!(model.get_selected_view().row, row1); + + model.on_page_up().unwrap(); + assert_eq!(model.get_selected_view().row, 1); +} + +#[test] +fn page_up_fails_on_row1() { + let model = new_empty_model(); + let mut model = UserModel::from_model(model); + model.on_arrow_up().unwrap(); + assert_eq!(model.get_selected_view().row, 1); +} diff --git a/base/src/test/user_model/test_on_area_selection.rs b/base/src/test/user_model/test_on_area_selection.rs new file mode 100644 index 0000000..f4025b2 --- /dev/null +++ b/base/src/test/user_model/test_on_area_selection.rs @@ -0,0 +1,33 @@ +#![allow(clippy::unwrap_used)] + +use crate::{ + constants::{DEFAULT_COLUMN_WIDTH, DEFAULT_WINDOW_WIDTH}, + test::util::new_empty_model, + UserModel, +}; + +#[test] +fn basic_test() { + let model = new_empty_model(); + let mut model = UserModel::from_model(model); + + model.on_area_selecting(2, 4).unwrap(); + let view = model.get_selected_view(); + assert_eq!(view.range, [1, 1, 2, 4]); +} + +// this checks that is we select in the boundary we automatically scroll +#[test] +fn scroll_right() { + let model = new_empty_model(); + let mut model = UserModel::from_model(model); + let window_width = DEFAULT_WINDOW_WIDTH as f64; + let column_width = DEFAULT_COLUMN_WIDTH; + let column_count = f64::floor(window_width / column_width) as i32; + model.set_selected_cell(3, column_count).unwrap(); + + model.on_area_selecting(3, column_count + 3).unwrap(); + let view = model.get_selected_view(); + assert_eq!(view.range, [3, column_count, 3, column_count + 3]); + assert_eq!(view.left_column, 4); +} diff --git a/base/src/test/user_model/test_on_expand_selected_range.rs b/base/src/test/user_model/test_on_expand_selected_range.rs new file mode 100644 index 0000000..46b93e5 --- /dev/null +++ b/base/src/test/user_model/test_on_expand_selected_range.rs @@ -0,0 +1,151 @@ +#![allow(clippy::unwrap_used)] + +use crate::{ + constants::{DEFAULT_COLUMN_WIDTH, DEFAULT_WINDOW_WIDTH, LAST_COLUMN}, + test::util::new_empty_model, + UserModel, +}; + +#[test] +fn arrow_right() { + let model = new_empty_model(); + let mut model = UserModel::from_model(model); + model.on_expand_selected_range("ArrowRight").unwrap(); + let view = model.get_selected_view(); + assert_eq!(view.range, [1, 1, 1, 2]); +} + +#[test] +fn arrow_right_decreases() { + // if the selected cell is on the upper right corner, right-arrow will decrease the size of teh area + let model = new_empty_model(); + let mut model = UserModel::from_model(model); + let (start_row, start_column, end_row, end_column) = (5, 3, 10, 8); + model.set_selected_cell(start_row, end_column).unwrap(); + model + .set_selected_range(start_row, start_column, end_row, end_column) + .unwrap(); + + model.on_expand_selected_range("ArrowRight").unwrap(); + let view = model.get_selected_view(); + assert_eq!( + view.range, + [start_row, start_column + 1, end_row, end_column] + ); +} + +#[test] +fn arrow_right_last_column() { + let model = new_empty_model(); + let mut model = UserModel::from_model(model); + model.set_selected_cell(1, LAST_COLUMN).unwrap(); + let view = model.get_selected_view(); + assert_eq!(view.range, [1, LAST_COLUMN, 1, LAST_COLUMN]); +} + +#[test] +fn arrow_right_scroll_right() { + let window_width = DEFAULT_WINDOW_WIDTH as f64; + let column_width = DEFAULT_COLUMN_WIDTH; + let column_count = f64::floor(window_width / column_width) as i32; + + let model = new_empty_model(); + let mut model = UserModel::from_model(model); + + // initially the column to the left is A + let view = model.get_selected_view(); + assert_eq!(view.left_column, 1); + + // We select all columns from 1 to the last visible + let (start_row, start_column, end_row, end_column) = (1, 1, 1, column_count); + model.set_selected_cell(start_row, start_column).unwrap(); + model + .set_selected_range(start_row, start_column, end_row, end_column) + .unwrap(); + + // Now we select one more column + model.on_expand_selected_range("ArrowRight").unwrap(); + + // The view has updated and the first visible column is B + let view = model.get_selected_view(); + assert_eq!( + view.range, + [start_row, start_column, end_row, end_column + 1] + ); + assert_eq!(view.left_column, 2); + + // now we click on cell B2 and we + model.set_selected_cell(2, 2).unwrap(); + model.on_expand_selected_range("ArrowLeft").unwrap(); + + let view = model.get_selected_view(); + assert_eq!(view.range, [2, 1, 2, 2]); + assert_eq!(view.left_column, 1); + + // a second arrow left won't do anything + model.on_expand_selected_range("ArrowLeft").unwrap(); + + let view = model.get_selected_view(); + assert_eq!(view.range, [2, 1, 2, 2]); + assert_eq!(view.left_column, 1); +} + +#[test] +fn arrow_left() { + let model = new_empty_model(); + let mut model = UserModel::from_model(model); + model.set_selected_cell(5, 3).unwrap(); + model.set_selected_range(5, 3, 10, 8).unwrap(); + model.on_expand_selected_range("ArrowLeft").unwrap(); + let view = model.get_selected_view(); + assert_eq!(view.range, [5, 3, 10, 7]); +} + +#[test] +fn arrow_left_left_border() { + let model = new_empty_model(); + let mut model = UserModel::from_model(model); + model.on_expand_selected_range("ArrowLeft").unwrap(); + let view = model.get_selected_view(); + assert_eq!(view.range, [1, 1, 1, 1]); +} + +#[test] +fn arrow_left_increases() { + // If the selected cell is on the top right corner + // arrow left increases the selected area by + let model = new_empty_model(); + let mut model = UserModel::from_model(model); + + let (start_row, start_column, end_row, end_column) = (4, 10, 4, 20); + model.set_selected_cell(start_row, end_column).unwrap(); + model + .set_selected_range(start_row, start_column, end_row, end_column) + .unwrap(); + model.on_expand_selected_range("ArrowLeft").unwrap(); + let view = model.get_selected_view(); + assert_eq!( + view.range, + [start_row, start_column - 1, end_row, end_column] + ); +} + +#[test] +fn arrow_left_scrolls_left() { + // If the selected cell is on the top right corner + // arrow left increases the selected area by + let model = new_empty_model(); + let mut model = UserModel::from_model(model); + + model.set_top_left_visible_cell(1, 50).unwrap(); + + model.set_selected_cell(1, 50).unwrap(); + // arrow left x 2 + model.on_expand_selected_range("ArrowLeft").unwrap(); + model.on_expand_selected_range("ArrowLeft").unwrap(); + + let view = model.get_selected_view(); + assert_eq!(view.range, [1, 48, 1, 50]); + assert_eq!(view.left_column, 48); + assert_eq!(view.column, 50); +} diff --git a/base/src/test/user_model/test_on_paste_styles.rs b/base/src/test/user_model/test_on_paste_styles.rs new file mode 100644 index 0000000..bb1b9d3 --- /dev/null +++ b/base/src/test/user_model/test_on_paste_styles.rs @@ -0,0 +1,48 @@ +#![allow(clippy::unwrap_used)] + +use crate::test::util::new_empty_model; +use crate::types::Fill; +use crate::UserModel; + +#[test] +fn simple_pasting() { + let model = new_empty_model(); + let mut model = UserModel::from_model(model); + let mut style = model.get_cell_style(0, 1, 1).unwrap(); + style.fill = Fill { + pattern_type: "solid".to_string(), + fg_color: Some("#FF5577".to_string()), + bg_color: Some("#33FF44".to_string()), + }; + let styles = vec![vec![style.clone()]]; + + model.set_selected_cell(5, 4).unwrap(); + model.set_selected_range(5, 4, 10, 9).unwrap(); + model.on_paste_styles(&styles).unwrap(); + + for row in 5..10 { + for column in 4..9 { + let cell_style = model.get_cell_style(0, row, column).unwrap(); + assert_eq!(cell_style, style); + } + } + + model.undo().unwrap(); + let base_style = model.get_cell_style(0, 100, 100).unwrap(); + + for row in 5..10 { + for column in 4..9 { + let cell_style = model.get_cell_style(0, row, column).unwrap(); + assert_eq!(cell_style, base_style); + } + } + + model.redo().unwrap(); + + for row in 5..10 { + for column in 4..9 { + let cell_style = model.get_cell_style(0, row, column).unwrap(); + assert_eq!(cell_style, style); + } + } +} diff --git a/base/src/test/user_model/test_view.rs b/base/src/test/user_model/test_view.rs index 5a6dcd6..3f9d907 100644 --- a/base/src/test/user_model/test_view.rs +++ b/base/src/test/user_model/test_view.rs @@ -52,19 +52,12 @@ fn set_the_cell_sets_the_range() { fn set_the_range_does_not_set_the_cell() { let model = new_empty_model(); let mut model = UserModel::from_model(model); - model.set_selected_range(5, 4, 10, 6).unwrap(); - assert_eq!(model.get_selected_sheet(), 0); - assert_eq!(model.get_selected_cell(), (0, 1, 1)); assert_eq!( - model.get_selected_view(), - SelectedView { - sheet: 0, - row: 1, - column: 1, - range: [5, 4, 10, 6], - top_row: 1, - left_column: 1 - } + model.set_selected_range(5, 4, 10, 6), + Err( + "The selected cells is not in one of the corners. Row: '1' and row range '(5, 10)'" + .to_string() + ) ); } diff --git a/base/src/test/user_model/test_window_size.rs b/base/src/test/user_model/test_window_size.rs new file mode 100644 index 0000000..85a6a0c --- /dev/null +++ b/base/src/test/user_model/test_window_size.rs @@ -0,0 +1,29 @@ +#![allow(clippy::unwrap_used)] + +use crate::{ + constants::{DEFAULT_ROW_HEIGHT, DEFAULT_WINDOW_HEIGH, DEFAULT_WINDOW_WIDTH}, + test::util::new_empty_model, + UserModel, +}; + +#[test] +fn basic_test() { + let model = new_empty_model(); + let mut model = UserModel::from_model(model); + let window_height = model.get_window_height().unwrap(); + assert_eq!(window_height, DEFAULT_WINDOW_HEIGH); + + let window_width = model.get_window_width().unwrap(); + assert_eq!(window_width, DEFAULT_WINDOW_WIDTH); + + // Set the window height to double the default and check that page_down behaves as expected + model.set_window_height((window_height * 2) as f64); + model.on_page_down().unwrap(); + + let row_height = DEFAULT_ROW_HEIGHT; + let row_count = f64::floor((window_height * 2) as f64 / row_height) as i32; + let view = model.get_selected_view(); + assert_eq!(view.row, 1 + row_count); + let scroll_y = model.get_scroll_y().unwrap(); + assert_eq!(scroll_y, (row_count as f64) * DEFAULT_ROW_HEIGHT); +} diff --git a/base/src/types.rs b/base/src/types.rs index ebda7c2..b62cdb5 100644 --- a/base/src/types.rs +++ b/base/src/types.rs @@ -33,6 +33,10 @@ pub struct WorkbookSettings { pub struct WorkbookView { /// The index of the currently selected sheet. pub sheet: u32, + /// The current width of the window + pub window_width: i64, + /// The current heigh of the window + pub window_height: i64, } /// An internal representation of an IronCalc Workbook diff --git a/base/src/user_model.rs b/base/src/user_model/common.rs similarity index 84% rename from base/src/user_model.rs rename to base/src/user_model/common.rs index 9351f53..c8f84d7 100644 --- a/base/src/user_model.rs +++ b/base/src/user_model/common.rs @@ -2,7 +2,6 @@ use std::{collections::HashMap, fmt::Debug}; -use bitcode::{Decode, Encode}; use serde::{Deserialize, Serialize}; use crate::{ @@ -13,180 +12,35 @@ use crate::{ }, model::Model, types::{ - Alignment, BorderItem, BorderStyle, Cell, CellType, Col, HorizontalAlignment, Row, - SheetProperties, Style, VerticalAlignment, + Alignment, BorderItem, BorderStyle, CellType, Col, HorizontalAlignment, SheetProperties, + Style, VerticalAlignment, }, utils::is_valid_hex_color, }; +use crate::user_model::history::{ + ColumnData, Diff, DiffList, DiffType, History, QueueDiffs, RowData, +}; + #[derive(Serialize, Deserialize)] -#[cfg_attr(test, derive(PartialEq, Debug))] -pub struct SelectedView { - pub sheet: u32, - pub row: i32, - pub column: i32, - pub range: [i32; 4], - pub top_row: i32, - pub left_column: i32, +pub enum BorderType { + All, + Inner, + Outer, + Top, + Right, + Bottom, + Left, + CenterH, + CenterV, + None, } -#[derive(Clone, Encode, Decode)] -struct RowData { - row: Option, - data: HashMap, -} - -#[derive(Clone, Encode, Decode)] -struct ColumnData { - column: Option, - data: HashMap, -} - -#[derive(Clone, Encode, Decode)] -enum Diff { - // Cell diffs - SetCellValue { - sheet: u32, - row: i32, - column: i32, - new_value: String, - old_value: Box>, - }, - CellClearContents { - sheet: u32, - row: i32, - column: i32, - old_value: Box>, - }, - CellClearAll { - sheet: u32, - row: i32, - column: i32, - old_value: Box>, - old_style: Box + + + + + + diff --git a/webapp/src/icons/border-top.svg b/webapp/src/icons/border-top.svg new file mode 100644 index 0000000..9051f73 --- /dev/null +++ b/webapp/src/icons/border-top.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/webapp/src/icons/decrease-decimal.svg b/webapp/src/icons/decrease-decimal.svg new file mode 100644 index 0000000..ab46d1e --- /dev/null +++ b/webapp/src/icons/decrease-decimal.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/webapp/src/icons/delete-column.svg b/webapp/src/icons/delete-column.svg new file mode 100644 index 0000000..36fc423 --- /dev/null +++ b/webapp/src/icons/delete-column.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/webapp/src/icons/delete-row.svg b/webapp/src/icons/delete-row.svg new file mode 100644 index 0000000..ffebabf --- /dev/null +++ b/webapp/src/icons/delete-row.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/webapp/src/icons/fx.svg b/webapp/src/icons/fx.svg new file mode 100644 index 0000000..fb9e203 --- /dev/null +++ b/webapp/src/icons/fx.svg @@ -0,0 +1,3 @@ + + + diff --git a/webapp/src/icons/increase-decimal.svg b/webapp/src/icons/increase-decimal.svg new file mode 100644 index 0000000..c8ffa01 --- /dev/null +++ b/webapp/src/icons/increase-decimal.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/webapp/src/icons/index.ts b/webapp/src/icons/index.ts new file mode 100644 index 0000000..5f2dd16 --- /dev/null +++ b/webapp/src/icons/index.ts @@ -0,0 +1,46 @@ +import DecimalPlacesDecreaseIcon from "./decrease-decimal.svg?react"; +import DecimalPlacesIncreaseIcon from "./increase-decimal.svg?react"; + +import BorderBottomIcon from "./border-bottom.svg?react"; +import BorderCenterHIcon from "./border-center-h.svg?react"; +import BorderCenterVIcon from "./border-center-v.svg?react"; +import BorderInnerIcon from "./border-inner.svg?react"; +import BorderLeftIcon from "./border-left.svg?react"; +import BorderNoneIcon from "./border-none.svg?react"; +import BorderOuterIcon from "./border-outer.svg?react"; +import BorderRightIcon from "./border-right.svg?react"; +import BorderStyleIcon from "./border-style.svg?react"; +import BorderTopIcon from "./border-top.svg?react"; + +import ArrowMiddleFromLine from "./arrow-middle-from-line.svg?react"; +import DeleteColumnIcon from "./delete-column.svg?react"; +import DeleteRowIcon from "./delete-row.svg?react"; +import InsertColumnLeftIcon from "./insert-column-left.svg?react"; +import InsertColumnRightIcon from "./insert-column-right.svg?react"; +import InsertRowAboveIcon from "./insert-row-above.svg?react"; +import InsertRowBelow from "./insert-row-below.svg?react"; + +import Fx from "./fx.svg?react"; + +export { + ArrowMiddleFromLine, + DecimalPlacesDecreaseIcon, + DecimalPlacesIncreaseIcon, + BorderBottomIcon, + BorderCenterHIcon, + BorderCenterVIcon, + BorderInnerIcon, + BorderLeftIcon, + BorderOuterIcon, + BorderRightIcon, + BorderTopIcon, + BorderNoneIcon, + BorderStyleIcon, + DeleteColumnIcon, + DeleteRowIcon, + InsertColumnLeftIcon, + InsertColumnRightIcon, + InsertRowAboveIcon, + InsertRowBelow, + Fx, +}; diff --git a/webapp/src/icons/insert-column-left.svg b/webapp/src/icons/insert-column-left.svg new file mode 100644 index 0000000..1d6e56d --- /dev/null +++ b/webapp/src/icons/insert-column-left.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/webapp/src/icons/insert-column-right.svg b/webapp/src/icons/insert-column-right.svg new file mode 100644 index 0000000..5cc5178 --- /dev/null +++ b/webapp/src/icons/insert-column-right.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/webapp/src/icons/insert-row-above.svg b/webapp/src/icons/insert-row-above.svg new file mode 100644 index 0000000..336d193 --- /dev/null +++ b/webapp/src/icons/insert-row-above.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/webapp/src/icons/insert-row-below.svg b/webapp/src/icons/insert-row-below.svg new file mode 100644 index 0000000..8b7f88b --- /dev/null +++ b/webapp/src/icons/insert-row-below.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/webapp/src/index.css b/webapp/src/index.css new file mode 100644 index 0000000..ea1e941 --- /dev/null +++ b/webapp/src/index.css @@ -0,0 +1,4 @@ +body { + margin: 0; + padding: 0; +} diff --git a/webapp/src/locale/en_us.json b/webapp/src/locale/en_us.json new file mode 100644 index 0000000..0852bdf --- /dev/null +++ b/webapp/src/locale/en_us.json @@ -0,0 +1,55 @@ +{ + "toolbar": { + "redo": "Redo", + "undo": "Undo", + "copy_styles": "Copy styles", + "euro": "Format as Euro", + "percentage": "Format as Percentage", + "bold": "Bold", + "italic": "Italic", + "underline": "Underline", + "strike_through": "Strikethrough", + "align_left": "Align left", + "align_right": "Align right", + "align_center": "Align center", + "format_number": "Format number", + "font_color": "Font color", + "fill_color": "Fill color", + "borders": "Borders", + "decimal_places_increase": "Increase decimal places", + "decimal_places_decrease": "Decrease decimal places", + "format_menu": { + "auto": "Auto", + "number": "Number", + "percentage": "Percentage", + "currency_eur": "Euro (EUR)", + "currency_usd": "Dollar (USD", + "currency_gbp": "British Pound (GBD)", + "date_short": "Short date", + "date_long": "Long date", + "custom": "Custom", + "number_example": "1,000.00", + "percentage_example": "10%", + "currency_eur_example": "€", + "currency_usd_example": "$", + "currency_gbp_example": "£", + "date_short_example": "09/24/2024", + "date_long_example": "Tuesday, September 24, 2024" + } + }, + "num_fmt": { + "title": "Custom number format", + "label": "Number format", + "save": "Save" + }, + "sheet_rename": { + "rename": "Save", + "label": "New name", + "title": "Rename Sheet" + }, + "formula_input": { + "update": "Update", + "label": "Formula", + "title": "Update formula" + } +} diff --git a/webapp/src/main.tsx b/webapp/src/main.tsx new file mode 100644 index 0000000..edb6ee8 --- /dev/null +++ b/webapp/src/main.tsx @@ -0,0 +1,15 @@ +import React from "react"; +import ReactDOM from "react-dom/client"; +import App from "./App.tsx"; +import "./index.css"; +import ThemeProvider from "@mui/material/styles/ThemeProvider"; +import { theme } from "./theme.ts"; + +// biome-ignore lint: we know the 'root' element exists. +ReactDOM.createRoot(document.getElementById("root")!).render( + + + + + , +); diff --git a/webapp/src/theme.ts b/webapp/src/theme.ts new file mode 100644 index 0000000..6f4080d --- /dev/null +++ b/webapp/src/theme.ts @@ -0,0 +1,66 @@ +import { createTheme } from "@mui/material/styles"; +import "./fonts.css"; + +export const theme = createTheme({ + typography: { + fontFamily: "Inter", + }, + palette: { + common: { + black: "#272525", + white: "#FFF", + }, + primary: { + main: "#F2994A", + light: "#EFAA6D", + dark: "#D68742", + contrastText: "#FFF", + }, + secondary: { + main: "#2F80ED", + light: "#4E92EC", + dark: "#2B6EC8", + contrastText: "#FFF", + }, + error: { + main: "#EB5757", + light: "#E77A7A", + dark: "#CB4C4C", + contrastText: "#FFF", + }, + warning: { + main: "#F2C94C", + light: "#EED384", + dark: "#D6B244", + contrastText: "#FFF", + }, + info: { + main: "#9E9E9E", + light: "#E0E0E0", + dark: "#757575", + contrastText: "#FFF", + }, + success: { + main: "#27AE60", + light: "#57BD82", + dark: "#239152", + contrastText: "#FFF", + }, + grey: { + "50": "#F2F2F2", + "100": "#F5F5F5", + "200": "#EEEEEE", + "300": "#E0E0E0", + "400": "#BDBDBD", + "500": "#9E9E9E", + "600": "#757575", + "700": "#616161", + "800": "#424242", + "900": "#333333", + A100: "#F2F2F2", + A200: "#EEEEEE", + A400: "#bdbdbd", + A700: "#616161", + }, + }, +}); diff --git a/webapp/src/vite-env.d.ts b/webapp/src/vite-env.d.ts new file mode 100644 index 0000000..b1f45c7 --- /dev/null +++ b/webapp/src/vite-env.d.ts @@ -0,0 +1,2 @@ +/// +/// diff --git a/webapp/tsconfig.json b/webapp/tsconfig.json new file mode 100644 index 0000000..47787c0 --- /dev/null +++ b/webapp/tsconfig.json @@ -0,0 +1,26 @@ +{ + "compilerOptions": { + "target": "ES2020", + "useDefineForClassFields": true, + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "module": "ESNext", + "skipLibCheck": true, + + /* Bundler mode */ + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "jsx": "react-jsx", + + /* Linting */ + "strict": true, + "noUnusedLocals": false, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true, + "esModuleInterop": true + }, + "include": ["src"], + "references": [{ "path": "./tsconfig.node.json" }] +} diff --git a/webapp/tsconfig.node.json b/webapp/tsconfig.node.json new file mode 100644 index 0000000..42872c5 --- /dev/null +++ b/webapp/tsconfig.node.json @@ -0,0 +1,10 @@ +{ + "compilerOptions": { + "composite": true, + "skipLibCheck": true, + "module": "ESNext", + "moduleResolution": "bundler", + "allowSyntheticDefaultImports": true + }, + "include": ["vite.config.ts"] +} diff --git a/webapp/vite.config.ts b/webapp/vite.config.ts new file mode 100644 index 0000000..60a7425 --- /dev/null +++ b/webapp/vite.config.ts @@ -0,0 +1,14 @@ +import { defineConfig } from 'vite'; +import react from '@vitejs/plugin-react'; +import svgr from 'vite-plugin-svgr'; + +// https://vitejs.dev/config/ +export default defineConfig({ + plugins: [react(), svgr()], + server: { + fs: { + // Allow serving files from one level up to the project root + allow: ['..'], + }, + }, +}); diff --git a/xlsx/src/import/mod.rs b/xlsx/src/import/mod.rs index d130237..cbc0d49 100644 --- a/xlsx/src/import/mod.rs +++ b/xlsx/src/import/mod.rs @@ -93,6 +93,8 @@ fn load_xlsx_from_reader( 0, WorkbookView { sheet: selected_sheet, + window_width: 800, + window_height: 600, }, ); Ok(Workbook { @@ -111,8 +113,6 @@ fn load_xlsx_from_reader( }) } -// Public methods - /// Imports a file from disk into an internal representation fn load_from_excel(file_name: &str, locale: &str, tz: &str) -> Result { let file_path = std::path::Path::new(file_name); @@ -134,6 +134,7 @@ pub fn load_from_xlsx(file_name: &str, locale: &str, tz: &str) -> Result Result { let contents = fs::read(file_name) .map_err(|e| XlsxError::IO(format!("Could not extract workbook name: {}", e)))?; - let workbook: Workbook = bitcode::decode(&contents).unwrap(); + let workbook: Workbook = bitcode::decode(&contents) + .map_err(|e| XlsxError::IO(format!("Failed to decode file: {}", e)))?; Model::from_workbook(workbook).map_err(XlsxError::Workbook) } diff --git a/xlsx/tests/example.ic b/xlsx/tests/example.ic index 1d31219..740cd6b 100644 Binary files a/xlsx/tests/example.ic and b/xlsx/tests/example.ic differ