diff --git a/CHANGELOG.md b/CHANGELOG.md index 9799ea2..732829b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Support dimming other areas while editing - Remember the selected cell when switching between sheets +- Added optional lazy loading for xlsx and xlsb files to improve performance with large files (enabled with -l flag) ### Fixed @@ -21,6 +22,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Edit cell content using vim shortcuts - Multiple UI improvements - Replace ratatui_textarea with tui_textarea +- Upgraded calamine to version 0.27.0 ## [0.2.0] - 2025-04-27 diff --git a/Cargo.lock b/Cargo.lock index b09dccf..89d947c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -94,6 +94,12 @@ dependencies = [ "derive_arbitrary", ] +[[package]] +name = "atoi_simd" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4790f9e8961209112beb783d85449b508673cf4a6a419c8449b210743ac4dbe9" + [[package]] name = "autocfg" version = "1.4.0" @@ -120,17 +126,19 @@ checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" [[package]] name = "calamine" -version = "0.22.1" +version = "0.27.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fe0ba51a659bb6c8bffd6f7c1c5ffafcafa0c97e4769411d841c3cc5c154ab47" +checksum = "6d80f81ba5c68206b9027e62346d49dc26fb32ffc4fe6ef7022a8ae21d348ccb" dependencies = [ + "atoi_simd", "byteorder", "codepage", "encoding_rs", + "fast-float2", "log", "quick-xml", "serde", - "zip 0.6.6", + "zip", ] [[package]] @@ -318,6 +326,12 @@ dependencies = [ "tui-textarea", ] +[[package]] +name = "fast-float2" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8eb564c5c7423d25c886fb561d1e4ee69f72354d16918afa32c08811f6b6a55" + [[package]] name = "flate2" version = "1.1.1" @@ -542,9 +556,9 @@ dependencies = [ [[package]] name = "quick-xml" -version = "0.30.0" +version = "0.37.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eff6510e86862b57b210fd8cbe8ed3f0d7d600b9c2863cd4549a2e033c66e956" +checksum = "331e97a1af0bf59823e6eadffe373d7b27f485be8748f71471c662c1f269b7fb" dependencies = [ "encoding_rs", "memchr", @@ -592,7 +606,7 @@ version = "0.86.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ce466a17c071a45249477993a1f1b6ceb447af42cbe9cdc65e30f38bab850688" dependencies = [ - "zip 2.6.1", + "zip", ] [[package]] @@ -1053,21 +1067,9 @@ checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" [[package]] name = "zip" -version = "0.6.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "760394e246e4c28189f19d488c058bf16f564016aefac5d32bb1f3b51d5e9261" -dependencies = [ - "byteorder", - "crc32fast", - "crossbeam-utils", - "flate2", -] - -[[package]] -name = "zip" -version = "2.6.1" +version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1dcb24d0152526ae49b9b96c1dcf71850ca1e0b882e4e28ed898a93c41334744" +checksum = "27c03817464f64e23f6f37574b4fdc8cf65925b5bfd2b0f2aedf959791941f88" dependencies = [ "arbitrary", "crc32fast", diff --git a/Cargo.toml b/Cargo.toml index 8acc0a2..8988b77 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -12,7 +12,7 @@ exclude = ["/.github", "CHANGELOG.md", ".gitignore"] [dependencies] ratatui = "0.24.0" crossterm = "0.27.0" -calamine = "0.22.1" +calamine = "0.27.0" anyhow = "1.0.79" clap = { version = "4.5.0", features = ["derive"] } rust_xlsxwriter = "0.86.0" diff --git a/README.md b/README.md index 1164577..f9e8721 100644 --- a/README.md +++ b/README.md @@ -75,6 +75,7 @@ excel-cli path/to/your/file.xlsx -j > data.json # (example) Save JSON output to - `--json-export`, `-j`: Export all sheets to JSON and output to stdout (for piping) - `--direction`, `-d`: Header direction in Excel: 'h' for horizontal (top rows), 'v' for vertical (left columns). Default: 'h' - `--header-count`, `-r`: Number of header rows (for horizontal) or columns (for vertical) in Excel. Default: 1 +- `--lazy-loading`, `-l`: Enable lazy loading for large Excel files (only loads data when needed) ## User Interface diff --git a/README_zh.md b/README_zh.md index 65dd014..72884be 100644 --- a/README_zh.md +++ b/README_zh.md @@ -75,6 +75,7 @@ excel-cli path/to/your/file.xlsx -j > data.json # (示例)将JSON输出保 - `--json-export`, `-j`:将所有工作表导出为 JSON 并输出到 stdout(用于管道传输) - `--direction`, `-d`:Excel 中的表头方向:'h'表示水平(顶部行),'v'表示垂直(左侧列)。默认:'h' - `--header-count`, `-r`:Excel 中的表头行数(水平方向)或列数(垂直方向)。默认:1 +- `--lazy-loading`, `-l`:启用大型 Excel 文件的懒加载功能(仅在需要时加载数据) ## 用户界面 diff --git a/src/actions/cell.rs b/src/actions/cell.rs index 5947084..a0eeea1 100644 --- a/src/actions/cell.rs +++ b/src/actions/cell.rs @@ -14,6 +14,7 @@ pub struct CellAction { } impl CellAction { + #[must_use] pub fn new( sheet_index: usize, sheet_name: String, diff --git a/src/actions/command.rs b/src/actions/command.rs index b6c04c5..b27e907 100644 --- a/src/actions/command.rs +++ b/src/actions/command.rs @@ -2,13 +2,12 @@ use super::types::{ActionCommand, ActionType}; impl ActionCommand { // Returns the action type of this command + #[must_use] pub fn get_action_type(&self) -> ActionType { match self { ActionCommand::Cell(action) => match action.action_type { - ActionType::Edit => ActionType::Edit, - ActionType::Cut => ActionType::Cut, ActionType::Paste => ActionType::Paste, - _ => ActionType::Edit, // Default case + _ => ActionType::Edit, // Default case including Edit and Cut }, ActionCommand::Row(_) => ActionType::DeleteRow, ActionCommand::MultiRow(_) => ActionType::DeleteMultiRows, diff --git a/src/actions/history.rs b/src/actions/history.rs index b8c5e66..04808c6 100644 --- a/src/actions/history.rs +++ b/src/actions/history.rs @@ -13,6 +13,7 @@ impl Default for UndoHistory { } impl UndoHistory { + #[must_use] pub fn new() -> Self { Self { undo_stack: Vec::with_capacity(100), // Pre-allocate capacity @@ -44,6 +45,7 @@ impl UndoHistory { } } + #[must_use] pub fn all_undone(&self) -> bool { self.undo_stack.is_empty() } diff --git a/src/app/edit.rs b/src/app/edit.rs index 9410da2..38c879e 100644 --- a/src/app/edit.rs +++ b/src/app/edit.rs @@ -10,7 +10,7 @@ impl AppState<'_> { pub fn start_editing(&mut self) { self.input_mode = InputMode::Editing; let content = self.get_cell_content(self.selected_cell.0, self.selected_cell.1); - self.input_buffer = content.clone(); + self.input_buffer.clone_from(&content); // Initialize TextArea with content and settings let mut text_area = tui_textarea::TextArea::default(); @@ -57,7 +57,7 @@ impl AppState<'_> { let old_cell = self.workbook.get_current_sheet().data[row][col].clone(); let mut new_cell = old_cell.clone(); - new_cell.value = content.clone(); + new_cell.value.clone_from(&content); let cell_action = CellAction::new( sheet_index, @@ -134,7 +134,7 @@ impl AppState<'_> { let old_cell = self.workbook.get_current_sheet().data[row][col].clone(); let mut new_cell = old_cell.clone(); - new_cell.value = content.clone(); + new_cell.value.clone_from(&content); let cell_action = CellAction::new( sheet_index, diff --git a/src/app/navigation.rs b/src/app/navigation.rs index bbfd344..7baeb96 100644 --- a/src/app/navigation.rs +++ b/src/app/navigation.rs @@ -108,9 +108,9 @@ impl AppState<'_> { || sheet.data[row][col].value.is_empty(); let message = if is_cell_empty { - format!("Jumped to first non-empty cell ({})", dir_name) + format!("Jumped to first non-empty cell ({dir_name})") } else { - format!("Jumped to last non-empty cell ({})", dir_name) + format!("Jumped to last non-empty cell ({dir_name})") }; self.add_notification(message); diff --git a/src/app/search.rs b/src/app/search.rs index 8ad3ceb..82b7105 100644 --- a/src/app/search.rs +++ b/src/app/search.rs @@ -33,7 +33,7 @@ impl AppState<'_> { pub fn execute_search(&mut self) { let query = self.text_area.lines().join("\n"); - self.input_buffer = query.clone(); + self.input_buffer.clone_from(&query); if query.is_empty() { self.input_mode = InputMode::Normal; @@ -41,7 +41,7 @@ impl AppState<'_> { } // Save the query for n/N commands - self.search_query = query.clone(); + self.search_query.clone_from(&query); // Set search direction based on mode match self.input_mode { @@ -53,7 +53,7 @@ impl AppState<'_> { self.search_results = self.find_all_matches(&query); if self.search_results.is_empty() { - self.add_notification(format!("Pattern not found: {}", query)); + self.add_notification(format!("Pattern not found: {query}")); self.current_search_idx = None; } else { // Find the appropriate result to jump to based on search direction and current position @@ -87,7 +87,7 @@ impl AppState<'_> { continue; } - if self.case_insensitive_contains(cell_content, &query_lower) { + if Self::case_insensitive_contains(cell_content, &query_lower) { results.push((row, col)); } } @@ -97,7 +97,7 @@ impl AppState<'_> { results } - fn case_insensitive_contains(&self, haystack: &str, needle: &str) -> bool { + fn case_insensitive_contains(haystack: &str, needle: &str) -> bool { if needle.is_empty() { return true; } @@ -123,17 +123,14 @@ impl AppState<'_> { pos.0 > current_pos.0 || (pos.0 == current_pos.0 && pos.1 > current_pos.1) }); - match next_idx { - Some(idx) => { - self.current_search_idx = Some(idx); - self.selected_cell = self.search_results[idx]; - } - None => { - // Wrap around to the first result - self.current_search_idx = Some(0); - self.selected_cell = self.search_results[0]; - self.add_notification("Search wrapped to top".to_string()); - } + if let Some(idx) = next_idx { + self.current_search_idx = Some(idx); + self.selected_cell = self.search_results[idx]; + } else { + // Wrap around to the first result + self.current_search_idx = Some(0); + self.selected_cell = self.search_results[0]; + self.add_notification("Search wrapped to top".to_string()); } } else { // Backward search @@ -141,18 +138,15 @@ impl AppState<'_> { pos.0 < current_pos.0 || (pos.0 == current_pos.0 && pos.1 < current_pos.1) }); - match prev_idx { - Some(idx) => { - self.current_search_idx = Some(idx); - self.selected_cell = self.search_results[idx]; - } - None => { - // Wrap around to the last result - let last_idx = self.search_results.len() - 1; - self.current_search_idx = Some(last_idx); - self.selected_cell = self.search_results[last_idx]; - self.add_notification("Search wrapped to bottom".to_string()); - } + if let Some(idx) = prev_idx { + self.current_search_idx = Some(idx); + self.selected_cell = self.search_results[idx]; + } else { + // Wrap around to the last result + let last_idx = self.search_results.len() - 1; + self.current_search_idx = Some(last_idx); + self.selected_cell = self.search_results[last_idx]; + self.add_notification("Search wrapped to bottom".to_string()); } } diff --git a/src/app/sheet.rs b/src/app/sheet.rs index fea3a05..0890d57 100644 --- a/src/app/sheet.rs +++ b/src/app/sheet.rs @@ -94,7 +94,20 @@ impl AppState<'_> { self.update_row_number_width(); - self.add_notification(format!("Switched to sheet: {}", new_sheet_name)); + // Check if the new sheet is loaded when using lazy loading + let is_lazy_loading = self.workbook.is_lazy_loading(); + let is_sheet_loaded = self.workbook.is_sheet_loaded(index); + + if is_lazy_loading && !is_sheet_loaded { + // If the sheet is not loaded, switch to LazyLoading mode + self.input_mode = crate::app::InputMode::LazyLoading; + self.add_notification(format!( + "Switched to sheet: {new_sheet_name} (press Enter to load)" + )); + } else { + self.add_notification(format!("Switched to sheet: {new_sheet_name}")); + } + Ok(()) } @@ -109,12 +122,9 @@ impl AppState<'_> { if zero_based_index < sheet_names.len() { match self.switch_sheet_by_index(zero_based_index) { - Ok(_) => return, + Ok(()) => return, Err(e) => { - self.add_notification(format!( - "Failed to switch to sheet {}: {}", - index, e - )); + self.add_notification(format!("Failed to switch to sheet {index}: {e}")); return; } } @@ -125,11 +135,10 @@ impl AppState<'_> { for (i, name) in sheet_names.iter().enumerate() { if name.eq_ignore_ascii_case(name_or_index) { match self.switch_sheet_by_index(i) { - Ok(_) => return, + Ok(()) => return, Err(e) => { self.add_notification(format!( - "Failed to switch to sheet '{}': {}", - name_or_index, e + "Failed to switch to sheet '{name_or_index}': {e}" )); return; } @@ -138,7 +147,7 @@ impl AppState<'_> { } // If we get here, no matching sheet was found - self.add_notification(format!("Sheet '{}' not found", name_or_index)); + self.add_notification(format!("Sheet '{name_or_index}' not found")); } pub fn delete_current_sheet(&mut self) { @@ -150,7 +159,7 @@ impl AppState<'_> { let column_widths = self.column_widths.clone(); match self.workbook.delete_current_sheet() { - Ok(_) => { + Ok(()) => { // Create the undo action let sheet_action = SheetAction { sheet_index, @@ -164,6 +173,8 @@ impl AppState<'_> { self.sheet_cell_positions.remove(¤t_sheet_name); let new_sheet_name = self.workbook.get_current_sheet_name(); + let new_sheet_index = self.workbook.get_current_sheet_index(); + let is_new_sheet_loaded = self.workbook.is_sheet_loaded(new_sheet_index); // Restore saved cell position for the new current sheet or use default if let Some(saved_position) = self.sheet_cell_positions.get(&new_sheet_name) { @@ -200,10 +211,19 @@ impl AppState<'_> { self.search_results.clear(); self.current_search_idx = None; - self.add_notification(format!("Deleted sheet: {}", current_sheet_name)); + // Check if the new current sheet is loaded when using lazy loading + if self.workbook.is_lazy_loading() && !is_new_sheet_loaded { + // If the sheet is not loaded, switch to LazyLoading mode + self.input_mode = crate::app::InputMode::LazyLoading; + self.add_notification(format!( + "Deleted sheet: {current_sheet_name}. Switched to sheet: {new_sheet_name} (press Enter to load)" + )); + } else { + self.add_notification(format!("Deleted sheet: {current_sheet_name}")); + } } Err(e) => { - self.add_notification(format!("Failed to delete sheet: {}", e)); + self.add_notification(format!("Failed to delete sheet: {e}")); } } } @@ -251,7 +271,7 @@ impl AppState<'_> { self.search_results.clear(); self.current_search_idx = None; - self.add_notification(format!("Deleted row {}", row)); + self.add_notification(format!("Deleted row {row}")); Ok(()) } @@ -297,7 +317,7 @@ impl AppState<'_> { self.search_results.clear(); self.current_search_idx = None; - self.add_notification(format!("Deleted row {}", row)); + self.add_notification(format!("Deleted row {row}")); Ok(()) } @@ -357,10 +377,7 @@ impl AppState<'_> { self.search_results.clear(); self.current_search_idx = None; - self.add_notification(format!( - "Deleted rows {} to {}", - start_row, effective_end_row - )); + self.add_notification(format!("Deleted rows {start_row} to {effective_end_row}")); Ok(()) } @@ -427,7 +444,8 @@ impl AppState<'_> { self.search_results.clear(); self.current_search_idx = None; - self.add_notification(format!("Deleted column {}", index_to_col_name(col))); + let col_name = index_to_col_name(col); + self.add_notification(format!("Deleted column {col_name}")); Ok(()) } @@ -493,7 +511,8 @@ impl AppState<'_> { self.search_results.clear(); self.current_search_idx = None; - self.add_notification(format!("Deleted column {}", index_to_col_name(col))); + let col_name = index_to_col_name(col); + self.add_notification(format!("Deleted column {col_name}")); Ok(()) } @@ -589,12 +608,25 @@ impl AppState<'_> { } pub fn auto_adjust_column_width(&mut self, col: Option) { - let sheet = self.workbook.get_current_sheet(); + // Get sheet information before any mutable operations + let is_loaded = self.workbook.get_current_sheet().is_loaded; + let max_cols = self.workbook.get_current_sheet().max_cols; let default_min_width = 5; + if !is_loaded && max_cols == 0 { + self.add_notification( + "Cannot adjust column widths in lazy loading mode until sheet is loaded" + .to_string(), + ); + return; + } + match col { // Adjust specific column Some(column) => { + // Ensure column_widths is large enough + self.ensure_column_widths(); + if column < self.column_widths.len() { // Calculate and set new column width let width = self.calculate_column_width(column); @@ -610,15 +642,21 @@ impl AppState<'_> { } // Adjust all columns None => { - for col_idx in 1..=sheet.max_cols { - let width = self.calculate_column_width(col_idx); - self.column_widths[col_idx] = width.max(default_min_width); - } + // Ensure column_widths is large enough + self.ensure_column_widths(); + + // Only process columns if there are any + if max_cols > 0 { + for col_idx in 1..=max_cols { + let width = self.calculate_column_width(col_idx); + self.column_widths[col_idx] = width.max(default_min_width); + } - let column = self.selected_cell.1; - self.ensure_column_visible(column); + let column = self.selected_cell.1; + self.ensure_column_visible(column); - self.add_notification("All column widths adjusted".to_string()); + self.add_notification("All column widths adjusted".to_string()); + } } } } diff --git a/src/app/state.rs b/src/app/state.rs index 3c0adf9..1e5d248 100644 --- a/src/app/state.rs +++ b/src/app/state.rs @@ -23,6 +23,8 @@ pub enum InputMode { SearchForward, SearchBackward, Help, + LazyLoading, + CommandInLazyLoading, } pub struct AppState<'a> { @@ -112,6 +114,16 @@ impl AppState<'_> { // Ensure a minimum width of 4 for row numbers let row_number_width = row_number_width.max(4); + // Check if the workbook is using lazy loading and the first sheet is not loaded + let is_lazy_loading = workbook.is_lazy_loading() && !workbook.is_sheet_loaded(0); + + // Set initial input mode based on lazy loading status + let initial_input_mode = if is_lazy_loading { + InputMode::LazyLoading + } else { + InputMode::Normal + }; + Ok(Self { workbook, file_path, @@ -120,7 +132,7 @@ impl AppState<'_> { start_col: 1, visible_rows: 30, // Default values, will be adjusted based on window size visible_cols: 15, // Default values, will be adjusted based on window size - input_mode: InputMode::Normal, + input_mode: initial_input_mode, input_buffer: String::new(), text_area, should_quit: false, @@ -213,6 +225,14 @@ impl AppState<'_> { return; } + // If in CommandInLazyLoading mode, return to LazyLoading mode + if let InputMode::CommandInLazyLoading = self.input_mode { + self.input_mode = InputMode::LazyLoading; + self.input_buffer = String::new(); + self.text_area = TextArea::default(); + return; + } + // Otherwise, cancel the current input self.input_mode = InputMode::Normal; self.input_buffer = String::new(); @@ -231,4 +251,9 @@ impl AppState<'_> { self.input_mode = InputMode::Command; self.input_buffer = String::new(); } + + pub fn start_command_in_lazy_loading_mode(&mut self) { + self.input_mode = InputMode::CommandInLazyLoading; + self.input_buffer = String::new(); + } } diff --git a/src/app/ui.rs b/src/app/ui.rs index 99fc3ad..f4075c6 100644 --- a/src/app/ui.rs +++ b/src/app/ui.rs @@ -102,7 +102,7 @@ impl AppState<'_> { self.should_quit = true; } Err(e) => { - self.add_notification(format!("Save failed: {}", e)); + self.add_notification(format!("Save failed: {e}")); self.input_mode = InputMode::Normal; } } @@ -120,7 +120,7 @@ impl AppState<'_> { self.add_notification("File saved".to_string()); } Err(e) => { - self.add_notification(format!("Save failed: {}", e)); + self.add_notification(format!("Save failed: {e}")); } } Ok(()) diff --git a/src/app/undo_manager.rs b/src/app/undo_manager.rs index 579ac5c..395e599 100644 --- a/src/app/undo_manager.rs +++ b/src/app/undo_manager.rs @@ -317,7 +317,7 @@ impl AppState<'_> { } if let Err(e) = self.workbook.delete_current_sheet() { - self.add_notification(format!("Failed to delete sheet: {}", e)); + self.add_notification(format!("Failed to delete sheet: {e}")); return Ok(()); } diff --git a/src/app/vim.rs b/src/app/vim.rs index feb2652..e9d6565 100644 --- a/src/app/vim.rs +++ b/src/app/vim.rs @@ -262,9 +262,8 @@ impl VimState { if self.mode == VimMode::Visual { textarea.cancel_selection(); return Transition::Mode(VimMode::Normal); - } else { - return Transition::Exit; } + return Transition::Exit; } // Scrolling diff --git a/src/commands/executor.rs b/src/commands/executor.rs index a983850..1742bd0 100644 --- a/src/commands/executor.rs +++ b/src/commands/executor.rs @@ -24,7 +24,7 @@ impl AppState<'_> { match command.as_str() { "w" => { if let Err(e) = self.save() { - self.add_notification(format!("Save failed: {}", e)); + self.add_notification(format!("Save failed: {e}")); } } "wq" | "x" => self.save_and_exit(), @@ -42,12 +42,12 @@ impl AppState<'_> { "y" => self.copy_cell(), "d" => { if let Err(e) = self.cut_cell() { - self.add_notification(format!("Cut failed: {}", e)); + self.add_notification(format!("Cut failed: {e}")); } } "put" | "pu" => { if let Err(e) = self.paste_cell() { - self.add_notification(format!("Paste failed: {}", e)); + self.add_notification(format!("Paste failed: {e}")); } } "nohlsearch" | "noh" => self.disable_search_highlight(), @@ -126,7 +126,7 @@ impl AppState<'_> { if parts.len() == 1 { // Delete current row if let Err(e) = self.delete_current_row() { - self.add_notification(format!("Failed to delete row: {}", e)); + self.add_notification(format!("Failed to delete row: {e}")); } return; } @@ -169,7 +169,7 @@ impl AppState<'_> { if parts.len() == 1 { // Delete current column if let Err(e) = self.delete_current_column() { - self.add_notification(format!("Failed to delete column: {}", e)); + self.add_notification(format!("Failed to delete column: {e}")); } return; } @@ -322,7 +322,7 @@ impl AppState<'_> { self.add_notification(format!("Exported to {}", new_filepath.display())); } Err(e) => { - self.add_notification(format!("Export failed: {}", e)); + self.add_notification(format!("Export failed: {e}")); } } } diff --git a/src/excel/sheet.rs b/src/excel/sheet.rs index 7add35d..57f32f4 100644 --- a/src/excel/sheet.rs +++ b/src/excel/sheet.rs @@ -6,4 +6,5 @@ pub struct Sheet { pub data: Vec>, pub max_rows: usize, pub max_cols: usize, + pub is_loaded: bool, } diff --git a/src/excel/workbook.rs b/src/excel/workbook.rs index 2bdaa71..92c47fc 100644 --- a/src/excel/workbook.rs +++ b/src/excel/workbook.rs @@ -1,126 +1,220 @@ use anyhow::{Context, Result}; -use calamine::{open_workbook_auto, DataType, Reader}; +use calamine::{open_workbook_auto, Data, Reader, Xls, Xlsx}; use chrono::Local; use rust_xlsxwriter::{Format, Workbook as XlsxWorkbook}; +use std::collections::HashSet; +use std::fs::File; +use std::io::BufReader; use std::path::Path; use crate::excel::{Cell, CellType, DataTypeInfo, Sheet}; -#[derive(Clone)] +pub enum CalamineWorkbook { + Xlsx(Box>>), + Xls(Xls>), + None, +} + +impl Clone for CalamineWorkbook { + fn clone(&self) -> Self { + CalamineWorkbook::None + } +} + pub struct Workbook { sheets: Vec, current_sheet_index: usize, file_path: String, is_modified: bool, + calamine_workbook: CalamineWorkbook, + lazy_loading: bool, + loaded_sheets: HashSet, // Track which sheets have been loaded +} + +impl Clone for Workbook { + fn clone(&self) -> Self { + Workbook { + sheets: self.sheets.clone(), + current_sheet_index: self.current_sheet_index, + file_path: self.file_path.clone(), + is_modified: self.is_modified, + calamine_workbook: CalamineWorkbook::None, + lazy_loading: false, + loaded_sheets: self.loaded_sheets.clone(), + } + } } -pub fn open_workbook>(path: P) -> Result { +pub fn open_workbook>(path: P, enable_lazy_loading: bool) -> Result { let path_str = path.as_ref().to_string_lossy().to_string(); + let path_ref = path.as_ref(); + + // Determine if the file format supports lazy loading + let extension = path_ref + .extension() + .and_then(|ext| ext.to_str()) + .map(|ext| ext.to_lowercase()); + + // Only enable lazy loading if both the flag is set AND the format supports it + let supports_lazy_loading = + enable_lazy_loading && matches!(extension.as_deref(), Some("xlsx" | "xlsm")); // Open workbook directly from path - let mut workbook = open_workbook_auto(&path).context("Unable to parse Excel file")?; + let mut workbook = open_workbook_auto(&path) + .with_context(|| format!("Unable to parse Excel file: {}", path_str))?; let sheet_names = workbook.sheet_names().to_vec(); - let mut sheets = Vec::new(); - - for name in &sheet_names { - let range = workbook - .worksheet_range(name) - .context(format!("Unable to read worksheet: {}", name))?; - let sheet = create_sheet_from_range(name, range?); - sheets.push(sheet); + + // Pre-allocate with the right capacity + let mut sheets = Vec::with_capacity(sheet_names.len()); + + // Store the original calamine workbook for lazy loading if enabled + let mut calamine_workbook = CalamineWorkbook::None; + + if supports_lazy_loading { + // For formats that support lazy loading, keep the original workbook + // and only load sheet metadata + for name in &sheet_names { + // Create a minimal sheet with just the name + let sheet = Sheet { + name: name.to_string(), + data: vec![vec![Cell::empty(); 1]; 1], + max_rows: 0, + max_cols: 0, + is_loaded: false, + }; + + sheets.push(sheet); + } + + // Try to reopen the file to get a fresh reader for lazy loading + if let Ok(file) = File::open(&path) { + let reader = BufReader::new(file); + + // Try to open as XLSX first + if let Ok(xlsx_workbook) = Xlsx::new(reader) { + calamine_workbook = CalamineWorkbook::Xlsx(Box::new(xlsx_workbook)); + } else { + // If not XLSX, try to open as XLS + if let Ok(file) = File::open(&path) { + let reader = BufReader::new(file); + if let Ok(xls_workbook) = Xls::new(reader) { + calamine_workbook = CalamineWorkbook::Xls(xls_workbook); + } + } + } + } + } else { + // For formats that don't support lazy loading or if lazy loading is disabled, + for name in &sheet_names { + let range = workbook + .worksheet_range(name) + .with_context(|| format!("Unable to read worksheet: {}", name))?; + + let mut sheet = create_sheet_from_range(name, range); + sheet.is_loaded = true; + sheets.push(sheet); + } } if sheets.is_empty() { anyhow::bail!("No worksheets found in file"); } + let mut loaded_sheets = HashSet::new(); + + if !supports_lazy_loading { + for i in 0..sheets.len() { + loaded_sheets.insert(i); + } + } + Ok(Workbook { sheets, current_sheet_index: 0, file_path: path_str, is_modified: false, + calamine_workbook, + lazy_loading: supports_lazy_loading, + loaded_sheets, }) } -fn create_sheet_from_range(name: &str, range: calamine::Range) -> Sheet { - let height = range.height(); - let width = range.width(); +fn create_sheet_from_range(name: &str, range: calamine::Range) -> Sheet { + let (height, width) = range.get_size(); + // Create a data grid with empty cells, adding 1 to dimensions for 1-based indexing let mut data = vec![vec![Cell::empty(); width + 1]; height + 1]; - for (row_idx, row) in range.rows().enumerate() { - for (col_idx, cell) in row.iter().enumerate() { - if let DataType::Empty = cell { - continue; + // Process only non-empty cells + for (row_idx, col_idx, cell) in range.used_cells() { + // Extract value, cell_type, and original_type from the Data + let (value, cell_type, original_type) = match cell { + Data::Empty => (String::new(), CellType::Empty, Some(DataTypeInfo::Empty)), + + Data::String(s) => { + let value = s.clone(); + (value, CellType::Text, Some(DataTypeInfo::String)) } - // Extract value, cell_type, and original_type from the DataType - let (value, cell_type, original_type) = match cell { - DataType::Empty => (String::new(), CellType::Empty, Some(DataTypeInfo::Empty)), - DataType::String(s) => { - let mut value = String::with_capacity(s.len()); - value.push_str(s); - (value, CellType::Text, Some(DataTypeInfo::String)) - } - DataType::Float(f) => { - let value = if *f == (*f as i64) as f64 && f.abs() < 1e10 { - (*f as i64).to_string() - } else { - f.to_string() - }; - (value, CellType::Number, Some(DataTypeInfo::Float(*f))) - } - DataType::Int(i) => (i.to_string(), CellType::Number, Some(DataTypeInfo::Int(*i))), - DataType::Bool(b) => ( - if *b { - "TRUE".to_string() - } else { - "FALSE".to_string() - }, - CellType::Boolean, - Some(DataTypeInfo::Bool(*b)), - ), - DataType::Error(e) => { - // Pre-allocate with capacity for error message - let mut value = String::with_capacity(15); - value.push_str("Error: "); - value.push_str(&format!("{:?}", e)); - (value, CellType::Text, Some(DataTypeInfo::Error)) - } - DataType::DateTime(dt) => ( - dt.to_string(), + Data::Float(f) => { + let value = if *f == (*f as i64) as f64 && f.abs() < 1e10 { + (*f as i64).to_string() + } else { + f.to_string() + }; + (value, CellType::Number, Some(DataTypeInfo::Float(*f))) + } + + Data::Int(i) => (i.to_string(), CellType::Number, Some(DataTypeInfo::Int(*i))), + + Data::Bool(b) => ( + if *b { + "TRUE".to_string() + } else { + "FALSE".to_string() + }, + CellType::Boolean, + Some(DataTypeInfo::Bool(*b)), + ), + + Data::Error(e) => { + let mut value = String::with_capacity(15); + value.push_str("Error: "); + value.push_str(&format!("{:?}", e)); + (value, CellType::Text, Some(DataTypeInfo::Error)) + } + + Data::DateTime(dt) => ( + dt.to_string(), + CellType::Date, + Some(DataTypeInfo::DateTime(dt.as_f64())), + ), + + Data::DateTimeIso(s) => { + let value = s.clone(); + ( + value.clone(), CellType::Date, - Some(DataTypeInfo::DateTime(*dt)), - ), - DataType::Duration(d) => ( - d.to_string(), + Some(DataTypeInfo::DateTimeIso(value)), + ) + } + + Data::DurationIso(s) => { + let value = s.clone(); + ( + value.clone(), CellType::Text, - Some(DataTypeInfo::Duration(*d)), - ), - DataType::DateTimeIso(s) => { - let value = s.to_string(); - ( - value.clone(), - CellType::Date, - Some(DataTypeInfo::DateTimeIso(value)), - ) - } - DataType::DurationIso(s) => { - let value = s.to_string(); - ( - value.clone(), - CellType::Text, - Some(DataTypeInfo::DurationIso(value)), - ) - } - }; + Some(DataTypeInfo::DurationIso(value)), + ) + } + }; - let is_formula = !value.is_empty() && value.starts_with('='); + let is_formula = !value.is_empty() && value.starts_with('='); - data[row_idx + 1][col_idx + 1] = - Cell::new_with_type(value, is_formula, cell_type, original_type); - } + // Store the cell in data grid (using 1-based indexing) + data[row_idx + 1][col_idx + 1] = + Cell::new_with_type(value, is_formula, cell_type, original_type); } Sheet { @@ -128,6 +222,7 @@ fn create_sheet_from_range(name: &str, range: calamine::Range) -> Shee data, max_rows: height, max_cols: width, + is_loaded: true, } } @@ -140,6 +235,51 @@ impl Workbook { &mut self.sheets[self.current_sheet_index] } + pub fn ensure_sheet_loaded(&mut self, sheet_index: usize, sheet_name: &str) -> Result<()> { + if !self.lazy_loading || self.sheets[sheet_index].is_loaded { + return Ok(()); + } + + // Load the sheet data from the calamine workbook + match &mut self.calamine_workbook { + CalamineWorkbook::Xlsx(xlsx) => { + if let Ok(range) = xlsx.worksheet_range(sheet_name) { + // Replace the placeholder sheet with a fully loaded one + let mut sheet = create_sheet_from_range(sheet_name, range); + + // Preserve the original name in case it was customized + let original_name = self.sheets[sheet_index].name.clone(); + sheet.name = original_name; + + self.sheets[sheet_index] = sheet; + + // Mark the sheet as loaded + self.loaded_sheets.insert(sheet_index); + } + } + CalamineWorkbook::Xls(xls) => { + if let Ok(range) = xls.worksheet_range(sheet_name) { + // Replace the placeholder sheet with a fully loaded one + let mut sheet = create_sheet_from_range(sheet_name, range); + + // Preserve the original name in case it was customized + let original_name = self.sheets[sheet_index].name.clone(); + sheet.name = original_name; + + self.sheets[sheet_index] = sheet; + + // Mark the sheet as loaded + self.loaded_sheets.insert(sheet_index); + } + } + CalamineWorkbook::None => { + return Err(anyhow::anyhow!("Cannot load sheet: no workbook available")); + } + } + + Ok(()) + } + pub fn get_sheet_by_index(&self, index: usize) -> Option<&Sheet> { self.sheets.get(index) } @@ -396,6 +536,18 @@ impl Workbook { &self.file_path } + pub fn is_lazy_loading(&self) -> bool { + self.lazy_loading + } + + pub fn is_sheet_loaded(&self, sheet_index: usize) -> bool { + if !self.lazy_loading || sheet_index >= self.sheets.len() { + return true; + } + + self.sheets[sheet_index].is_loaded + } + pub fn save(&mut self) -> Result<()> { if !self.is_modified { println!("No changes to save."); diff --git a/src/main.rs b/src/main.rs index a93d2ac..2490fb8 100644 --- a/src/main.rs +++ b/src/main.rs @@ -27,6 +27,10 @@ struct Cli { /// Number of header rows (for horizontal) or columns (for vertical) in JSON export #[arg(long, short = 'r', default_value = "1")] header_count: usize, + + /// Enable lazy loading for large Excel files + #[arg(long, short = 'l')] + lazy_loading: bool, } fn main() -> Result<()> { @@ -38,14 +42,13 @@ fn main() -> Result<()> { } // Open Excel file - let workbook = excel::open_workbook(&cli.file_path)?; + let workbook = excel::open_workbook(&cli.file_path, cli.lazy_loading)?; // If JSON export flag is set, export to stdout and exit if cli.json_export { // Parse header direction - let direction = match json_export::HeaderDirection::from_str(&cli.direction) { - Ok(dir) => dir, - Err(_) => anyhow::bail!("Invalid header direction: {}", cli.direction), + let Ok(direction) = json_export::HeaderDirection::from_str(&cli.direction) else { + anyhow::bail!("Invalid header direction: {}", cli.direction) }; // Generate JSON for all sheets @@ -54,7 +57,7 @@ fn main() -> Result<()> { // Serialize to JSON and print to stdout let json_string = json_export::serialize_to_json(&all_sheets)?; - println!("{}", json_string); + println!("{json_string}"); return Ok(()); } diff --git a/src/ui/handlers.rs b/src/ui/handlers.rs index e5877ae..b92439a 100644 --- a/src/ui/handlers.rs +++ b/src/ui/handlers.rs @@ -16,9 +16,11 @@ pub fn handle_key_event(app_state: &mut AppState, key: KeyEvent) { } InputMode::Editing => handle_editing_mode(app_state, key), InputMode::Command => handle_command_mode(app_state, key.code), + InputMode::CommandInLazyLoading => handle_command_in_lazy_loading_mode(app_state, key.code), InputMode::SearchForward => handle_search_mode(app_state, key.code), InputMode::SearchBackward => handle_search_mode(app_state, key.code), InputMode::Help => handle_help_mode(app_state, key.code), + InputMode::LazyLoading => handle_lazy_loading_mode(app_state, key.code), } } @@ -39,7 +41,7 @@ fn handle_ctrl_key(app_state: &mut AppState, key_code: KeyCode) { } KeyCode::Char('r') => { if let Err(e) = app_state.redo() { - app_state.add_notification(format!("Redo failed: {}", e)); + app_state.add_notification(format!("Redo failed: {e}")); } } _ => {} @@ -56,11 +58,56 @@ fn handle_command_mode(app_state: &mut AppState, key_code: KeyCode) { } } +fn handle_command_in_lazy_loading_mode(app_state: &mut AppState, key_code: KeyCode) { + match key_code { + KeyCode::Enter => { + // Execute the command but stay in lazy loading mode if needed + let current_index = app_state.workbook.get_current_sheet_index(); + let is_sheet_loaded = app_state.workbook.is_sheet_loaded(current_index); + + // Execute the command + app_state.execute_command(); + + // If the sheet is still not loaded after command execution, switch back to LazyLoading mode + if !is_sheet_loaded + && !app_state + .workbook + .is_sheet_loaded(app_state.workbook.get_current_sheet_index()) + && matches!(app_state.input_mode, InputMode::Normal) + { + app_state.input_mode = InputMode::LazyLoading; + } + } + KeyCode::Esc => { + // Return to LazyLoading mode + app_state.input_mode = InputMode::LazyLoading; + app_state.input_buffer = String::new(); + } + KeyCode::Backspace => app_state.delete_char_from_input(), + KeyCode::Char(c) => app_state.add_char_to_input(c), + _ => {} + } +} + fn handle_normal_mode(app_state: &mut AppState, key_code: KeyCode) { match key_code { KeyCode::Enter => { app_state.g_pressed = false; - app_state.start_editing(); + + // Check if the current sheet is loaded + let index = app_state.workbook.get_current_sheet_index(); + let sheet_name = app_state.workbook.get_current_sheet_name(); + + if app_state.workbook.is_lazy_loading() && !app_state.workbook.is_sheet_loaded(index) { + // If the sheet is not loaded, load it first + if let Err(e) = app_state.workbook.ensure_sheet_loaded(index, &sheet_name) { + app_state.add_notification(format!("Failed to load sheet: {e}")); + } else { + app_state.start_editing(); + } + } else { + app_state.start_editing(); + } } KeyCode::Char('h') => { app_state.g_pressed = false; @@ -81,10 +128,10 @@ fn handle_normal_mode(app_state: &mut AppState, key_code: KeyCode) { KeyCode::Char('u') => { app_state.g_pressed = false; if let Err(e) = app_state.undo() { - app_state.add_notification(format!("Undo failed: {}", e)); + app_state.add_notification(format!("Undo failed: {e}")); } } - KeyCode::Char('=') | KeyCode::Char('+') => { + KeyCode::Char('=' | '+') => { app_state.g_pressed = false; app_state.adjust_info_panel_height(1); } @@ -95,13 +142,13 @@ fn handle_normal_mode(app_state: &mut AppState, key_code: KeyCode) { KeyCode::Char('[') => { app_state.g_pressed = false; if let Err(e) = app_state.prev_sheet() { - app_state.add_notification(format!("Failed to switch to previous sheet: {}", e)); + app_state.add_notification(format!("Failed to switch to previous sheet: {e}")); } } KeyCode::Char(']') => { app_state.g_pressed = false; if let Err(e) = app_state.next_sheet() { - app_state.add_notification(format!("Failed to switch to next sheet: {}", e)); + app_state.add_notification(format!("Failed to switch to next sheet: {e}")); } } KeyCode::Char('g') => { @@ -135,13 +182,13 @@ fn handle_normal_mode(app_state: &mut AppState, key_code: KeyCode) { KeyCode::Char('d') => { app_state.g_pressed = false; if let Err(e) = app_state.cut_cell() { - app_state.add_notification(format!("Cut failed: {}", e)); + app_state.add_notification(format!("Cut failed: {e}")); } } KeyCode::Char('p') => { app_state.g_pressed = false; if let Err(e) = app_state.paste_cell() { - app_state.add_notification(format!("Paste failed: {}", e)); + app_state.add_notification(format!("Paste failed: {e}")); } } KeyCode::Char(':') => { @@ -214,7 +261,7 @@ fn handle_editing_mode(app_state: &mut AppState, key: KeyEvent) { }; if let Err(e) = app_state.handle_vim_input(input) { - app_state.add_notification(format!("Vim input error: {}", e)); + app_state.add_notification(format!("Vim input error: {e}")); } } @@ -252,17 +299,68 @@ fn key_code_to_tui_key(key_code: KeyCode) -> Key { KeyCode::PageUp => Key::PageUp, KeyCode::PageDown => Key::PageDown, KeyCode::Tab => Key::Tab, - KeyCode::BackTab => Key::Null, // BackTab not supported in tui-textarea KeyCode::Delete => Key::Delete, - KeyCode::Insert => Key::Null, // Insert not supported in tui-textarea + // BackTab and Insert not supported in tui-textarea + KeyCode::BackTab | KeyCode::Insert => Key::Null, KeyCode::Esc => Key::Esc, KeyCode::Char(c) => Key::Char(c), KeyCode::F(n) => Key::F(n), - KeyCode::Null => Key::Null, _ => Key::Null, } } +fn handle_lazy_loading_mode(app_state: &mut AppState, key_code: KeyCode) { + match key_code { + KeyCode::Enter => { + let index = app_state.workbook.get_current_sheet_index(); + let sheet_name = app_state.workbook.get_current_sheet_name(); + + // Load the sheet + if let Err(e) = app_state.workbook.ensure_sheet_loaded(index, &sheet_name) { + app_state.add_notification(format!("Failed to load sheet: {e}")); + } + + app_state.input_mode = InputMode::Normal; + } + KeyCode::Char('[') => { + // Switch to previous sheet + let current_index = app_state.workbook.get_current_sheet_index(); + + if current_index == 0 { + app_state.add_notification("Already at the first sheet".to_string()); + } else { + // The method will automatically set the input mode to LazyLoading if the sheet is not loaded + if let Err(e) = app_state.switch_sheet_by_index(current_index - 1) { + app_state.add_notification(format!("Failed to switch to previous sheet: {e}")); + } + } + } + KeyCode::Char(']') => { + // Switch to next sheet + let current_index = app_state.workbook.get_current_sheet_index(); + let sheet_count = app_state.workbook.get_sheet_names().len(); + + if current_index >= sheet_count - 1 { + app_state.add_notification("Already at the last sheet".to_string()); + } else { + // The method will automatically set the input mode to LazyLoading if the sheet is not loaded + if let Err(e) = app_state.switch_sheet_by_index(current_index + 1) { + app_state.add_notification(format!("Failed to switch to next sheet: {e}")); + } + } + } + KeyCode::Char(':') => { + // Allow entering command mode from lazy loading mode + app_state.start_command_in_lazy_loading_mode(); + } + _ => { + app_state.add_notification( + "Press Enter to load the sheet data, or use [ and ] to switch sheets".to_string(), + ); + } + } +} + fn handle_help_mode(app_state: &mut AppState, key_code: KeyCode) { let line_count = app_state.help_text.lines().count(); diff --git a/src/ui/render.rs b/src/ui/render.rs index d84efd1..2f4935d 100644 --- a/src/ui/render.rs +++ b/src/ui/render.rs @@ -136,6 +136,20 @@ fn ui(f: &mut Frame, app_state: &mut AppState) { if let InputMode::Help = app_state.input_mode { draw_help_popup(f, app_state, f.size()); } + + // If in lazy loading mode or CommandInLazyLoading mode and the current sheet is not loaded, draw the lazy loading overlay + match app_state.input_mode { + InputMode::LazyLoading | InputMode::CommandInLazyLoading => { + let current_index = app_state.workbook.get_current_sheet_index(); + if !app_state.workbook.is_sheet_loaded(current_index) { + draw_lazy_loading_overlay(f, app_state, chunks[1]); + } else if matches!(app_state.input_mode, InputMode::LazyLoading) { + // If the sheet is loaded, switch back to Normal mode + app_state.input_mode = crate::app::InputMode::Normal; + } + } + _ => {} + } } fn draw_spreadsheet(f: &mut Frame, app_state: &AppState, area: Rect) { @@ -377,69 +391,66 @@ fn draw_info_panel(f: &mut Frame, app_state: &mut AppState, area: Rect) { let cell_ref = cell_reference(app_state.selected_cell); // Handle the top panel based on the input mode - match app_state.input_mode { - InputMode::Editing => { - let (vim_mode_str, mode_color) = if let Some(vim_state) = &app_state.vim_state { - match vim_state.mode { - crate::app::VimMode::Normal => ("NORMAL", Color::Green), - crate::app::VimMode::Insert => ("INSERT", Color::LightBlue), - crate::app::VimMode::Visual => ("VISUAL", Color::Yellow), - crate::app::VimMode::Operator(op) => { - let op_str = match op { - 'y' => "YANK", - 'd' => "DELETE", - 'c' => "CHANGE", - _ => "OPERATOR", - }; - (op_str, Color::LightRed) - } + if let InputMode::Editing = app_state.input_mode { + let (vim_mode_str, mode_color) = if let Some(vim_state) = &app_state.vim_state { + match vim_state.mode { + crate::app::VimMode::Normal => ("NORMAL", Color::Green), + crate::app::VimMode::Insert => ("INSERT", Color::LightBlue), + crate::app::VimMode::Visual => ("VISUAL", Color::Yellow), + crate::app::VimMode::Operator(op) => { + let op_str = match op { + 'y' => "YANK", + 'd' => "DELETE", + 'c' => "CHANGE", + _ => "OPERATOR", + }; + (op_str, Color::LightRed) } - } else { - ("VIM", Color::White) - }; + } + } else { + ("VIM", Color::White) + }; - let title = Line::from(vec![ - Span::raw(" Editing Cell "), - Span::raw(cell_ref.clone()), - Span::raw(" - "), - Span::styled( - vim_mode_str, - Style::default().fg(mode_color).add_modifier(Modifier::BOLD), - ), - Span::raw(" "), - ]); - - let edit_block = Block::default() - .borders(Borders::ALL) - .border_style(Style::default().fg(Color::LightCyan)) - .title(title); - - // Calculate inner area with padding - let inner_area = edit_block.inner(chunks[0]); - let padded_area = Rect { - x: inner_area.x + 1, // Add 1 character padding on the left - y: inner_area.y, - width: inner_area.width.saturating_sub(2), // Subtract 2 for left and right padding - height: inner_area.height, - }; + let title = Line::from(vec![ + Span::raw(" Editing Cell "), + Span::raw(cell_ref.clone()), + Span::raw(" - "), + Span::styled( + vim_mode_str, + Style::default().fg(mode_color).add_modifier(Modifier::BOLD), + ), + Span::raw(" "), + ]); + + let edit_block = Block::default() + .borders(Borders::ALL) + .border_style(Style::default().fg(Color::LightCyan)) + .title(title); + + // Calculate inner area with padding + let inner_area = edit_block.inner(chunks[0]); + let padded_area = Rect { + x: inner_area.x + 1, // Add 1 character padding on the left + y: inner_area.y, + width: inner_area.width.saturating_sub(2), // Subtract 2 for left and right padding + height: inner_area.height, + }; - f.render_widget(edit_block, chunks[0]); - f.render_widget(app_state.text_area.widget(), padded_area); - } - _ => { - // Get cell content - let content = app_state.get_cell_content(row, col); + f.render_widget(edit_block, chunks[0]); + f.render_widget(app_state.text_area.widget(), padded_area); + } else { + // Get cell content + let content = app_state.get_cell_content(row, col); - let title = format!(" Cell {} Content ", cell_ref); - let cell_block = Block::default().borders(Borders::ALL).title(title); + let title = format!(" Cell {cell_ref} Content "); + let cell_block = Block::default().borders(Borders::ALL).title(title); - // Create paragraph with cell content - let cell_paragraph = Paragraph::new(content) - .block(cell_block) - .wrap(ratatui::widgets::Wrap { trim: false }); + // Create paragraph with cell content + let cell_paragraph = Paragraph::new(content) + .block(cell_block) + .wrap(ratatui::widgets::Wrap { trim: false }); - f.render_widget(cell_paragraph, chunks[0]); - } + f.render_widget(cell_paragraph, chunks[0]); } // Create notification block @@ -503,7 +514,7 @@ fn draw_status_bar(f: &mut Frame, app_state: &AppState, area: Rect) { f.render_widget(status_widget, area); } - InputMode::Command => { + InputMode::Command | InputMode::CommandInLazyLoading => { // Create a styled text with different colors for command and parameters let mut spans = vec![Span::styled(":", Style::default())]; let command_spans = parse_command(&app_state.input_buffer); @@ -552,6 +563,51 @@ fn draw_status_bar(f: &mut Frame, app_state: &AppState, area: Rect) { InputMode::Help => { // No status bar in help mode } + + InputMode::LazyLoading => { + // Show a status message for lazy loading mode + let status_widget = Paragraph::new( + "Sheet data not loaded... Press Enter to load, [ and ] to switch sheets, :delsheet to delete current sheet, :q to quit, :q! to quit without saving", + ) + .style(Style::default().fg(Color::LightYellow)) + .alignment(ratatui::layout::Alignment::Left); + + f.render_widget(status_widget, area); + } + } +} + +fn draw_lazy_loading_overlay(f: &mut Frame, _app_state: &AppState, area: Rect) { + // Create a semi-transparent overlay + let overlay = Block::default() + .style(Style::default().bg(Color::Black).fg(Color::White)) + .borders(Borders::ALL) + .border_style(Style::default().fg(Color::LightCyan)); + + f.render_widget(Clear, area); + f.render_widget(overlay, area); + + // Calculate center position for the message + let message = "Press Enter to load the sheet, [ and ] to switch sheets"; + let width = message.len() as u16; + let x = area.x + (area.width.saturating_sub(width)) / 2; + let y = area.y + area.height / 2; + + if x < area.width && y < area.height { + let message_area = Rect { + x, + y, + width: width.min(area.width), + height: 1, + }; + + let message_widget = Paragraph::new(message).style( + Style::default() + .fg(Color::LightYellow) + .add_modifier(Modifier::BOLD), + ); + + f.render_widget(message_widget, message_area); } } @@ -635,7 +691,7 @@ fn draw_title_with_tabs(f: &mut Frame, app_state: &AppState, area: Rect) { .and_then(|n| n.to_str()) .unwrap_or("Untitled"); - let title_content = format!(" {} ", file_name); + let title_content = format!(" {file_name} "); let title_width = title_content .chars() diff --git a/src/utils/cell_navigation.rs b/src/utils/cell_navigation.rs index 6550772..40ef902 100644 --- a/src/utils/cell_navigation.rs +++ b/src/utils/cell_navigation.rs @@ -12,6 +12,7 @@ pub enum Direction { /// Find non-empty cell in specified direction /// /// Returns the position of found cell, or None if already at boundary +#[must_use] pub fn find_non_empty_cell( sheet: &Sheet, current_pos: (usize, usize), @@ -97,9 +98,8 @@ pub fn find_non_empty_cell( if row < sheet.data.len() && c < sheet.data[row].len() { if sheet.data[row][c].value.is_empty() { return Some((row, c + 1)); - } else { - last_non_empty = c; } + last_non_empty = c; } else { return Some((row, c + 1)); } @@ -114,9 +114,8 @@ pub fn find_non_empty_cell( if row < sheet.data.len() && c < sheet.data[row].len() { if sheet.data[row][c].value.is_empty() { return Some((row, c - 1)); - } else { - last_non_empty = c; } + last_non_empty = c; } else { return Some((row, c - 1)); } @@ -131,9 +130,8 @@ pub fn find_non_empty_cell( if r < sheet.data.len() && col < sheet.data[r].len() { if sheet.data[r][col].value.is_empty() { return Some((r + 1, col)); - } else { - last_non_empty = r; } + last_non_empty = r; } else { return Some((r + 1, col)); } @@ -148,9 +146,8 @@ pub fn find_non_empty_cell( if r < sheet.data.len() && col < sheet.data[r].len() { if sheet.data[r][col].value.is_empty() { return Some((r - 1, col)); - } else { - last_non_empty = r; } + last_non_empty = r; } else { return Some((r - 1, col)); } diff --git a/src/utils/helpers.rs b/src/utils/helpers.rs index 320f521..57cc389 100644 --- a/src/utils/helpers.rs +++ b/src/utils/helpers.rs @@ -1,3 +1,4 @@ +#[must_use] pub fn index_to_col_name(index: usize) -> String { let mut col_name = String::new(); let mut n = index; @@ -15,6 +16,7 @@ pub fn index_to_col_name(index: usize) -> String { col_name } +#[must_use] pub fn col_name_to_index(name: &str) -> Option { let mut result = 0; @@ -31,6 +33,7 @@ pub fn col_name_to_index(name: &str) -> Option { } // Format cell reference (e.g., A1, B2) +#[must_use] pub fn cell_reference(cell: (usize, usize)) -> String { format!("{}{}", index_to_col_name(cell.1), cell.0) }