diff --git a/CHANGELOG.md b/CHANGELOG.md index ddf6634..83b9bf9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- `:freeze` and `:unfreeze` commands to freeze and unfreeze rows and columns at the current or specified cell. + ## [1.3.1] - 2026-04-24 ### Added diff --git a/README.md b/README.md index 5e89f14..52bb8a5 100644 --- a/README.md +++ b/README.md @@ -398,6 +398,9 @@ The JSON files are saved in the same directory as the original Excel file. - `:dc` - Delete the current column - `:dc [col]` - Delete a specific column (e.g., `:dc A` or `:dc a` or `:dc 1` all delete column A) - `:dc [start] [end]` - Delete a range of columns (e.g., `:dc A C` or `:dc a c` deletes columns A through C) +- `:freeze` - Freeze rows above and columns left of the current cell +- `:freeze [cell]` - Freeze panes at a specific cell (e.g., `:freeze B2` freezes row 1 and column A) +- `:unfreeze` - Clear frozen panes for the current sheet ### Other Commands diff --git a/README_zh.md b/README_zh.md index b74e2fc..9f983f0 100644 --- a/README_zh.md +++ b/README_zh.md @@ -399,6 +399,9 @@ JSON 文件保存在原始 Excel 文件所在目录。 - `:dc` — 删除当前列 - `:dc [列]` — 删除指定列(如 `:dc A`、`:dc a` 或 `:dc 1` 都删除 A 列) - `:dc [起始] [结束]` — 删除列范围(如 `:dc A C` 或 `:dc a c` 删除 A 到 C 列) +- `:freeze` — 按当前单元格冻结其上方行和左侧列 +- `:freeze [单元格]` — 按指定单元格冻结窗格(如 `:freeze B2` 冻结第 1 行和 A 列) +- `:unfreeze` — 取消当前工作表的冻结窗格 ### 其他命令 diff --git a/src/app/help.rs b/src/app/help.rs index 2da0fbf..973a9c6 100644 --- a/src/app/help.rs +++ b/src/app/help.rs @@ -115,6 +115,14 @@ pub const LEFT_HELP_SECTIONS: &[HelpSection] = &[ keys: ":dc ", description: "Delete column range", }, + HelpEntry { + keys: ":freeze [cell]", + description: "Freeze panes at cell", + }, + HelpEntry { + keys: ":unfreeze", + description: "Clear frozen panes", + }, ], }, ]; diff --git a/src/app/navigation.rs b/src/app/navigation.rs index 7baeb96..719b77f 100644 --- a/src/app/navigation.rs +++ b/src/app/navigation.rs @@ -16,10 +16,27 @@ impl AppState<'_> { } pub fn handle_scrolling(&mut self) { - if self.selected_cell.0 < self.start_row { - self.start_row = self.selected_cell.0; - } else if self.selected_cell.0 >= self.start_row + self.visible_rows { - self.start_row = self.selected_cell.0 - self.visible_rows + 1; + let frozen_rows = self.workbook.get_current_sheet().freeze_panes.rows; + let min_scroll_row = frozen_rows + 1; + + if frozen_rows > 0 && self.start_row < min_scroll_row { + self.start_row = min_scroll_row; + } + + if self.selected_cell.0 > frozen_rows { + let frozen_rows_visible = if self.visible_rows > 1 { + frozen_rows.min(self.visible_rows - 1) + } else { + 0 + }; + let scroll_rows_visible = self.visible_rows.saturating_sub(frozen_rows_visible).max(1); + + if self.selected_cell.0 < self.start_row { + self.start_row = self.selected_cell.0.max(min_scroll_row); + } else if self.selected_cell.0 >= self.start_row + scroll_rows_visible { + self.start_row = + (self.selected_cell.0 - scroll_rows_visible + 1).max(min_scroll_row); + } } self.handle_column_scrolling(); @@ -138,17 +155,34 @@ impl AppState<'_> { } pub fn ensure_column_visible(&mut self, column: usize) { + let frozen_cols = self.workbook.get_current_sheet().freeze_panes.cols; + let min_scroll_col = frozen_cols + 1; + + if frozen_cols > 0 && self.start_col < min_scroll_col { + self.start_col = min_scroll_col; + } + + if column <= frozen_cols { + return; + } + // If column is to the left of visible area, adjust start_col if column < self.start_col { - self.start_col = column; + self.start_col = column.max(min_scroll_col); return; } - let last_visible_col = self.start_col + self.visible_cols - 1; + let frozen_cols_visible = if self.visible_cols > 1 { + frozen_cols.min(self.visible_cols - 1) + } else { + 0 + }; + let scroll_cols_visible = self.visible_cols.saturating_sub(frozen_cols_visible).max(1); + let last_visible_col = self.start_col + scroll_cols_visible - 1; // If column is to the right of visible area, adjust start_col to make it visible if column > last_visible_col { - self.start_col = (column - self.visible_cols + 1).max(1); + self.start_col = (column - scroll_cols_visible + 1).max(min_scroll_col); return; } @@ -157,10 +191,10 @@ impl AppState<'_> { let max_col = sheet.max_cols; // Only apply margin logic if not at the max column - if column < max_col && column == last_visible_col && self.visible_cols > 1 { + if column < max_col && column == last_visible_col && scroll_cols_visible > 1 { // Adjust start column to show more columns to the left // This creates a margin on the right - self.start_col = (column - (self.visible_cols - 2)).max(1); + self.start_col = (column - (scroll_cols_visible - 2)).max(min_scroll_col); } } } diff --git a/src/cli/check.rs b/src/cli/check.rs index da4b06f..dc08e55 100644 --- a/src/cli/check.rs +++ b/src/cli/check.rs @@ -1046,6 +1046,7 @@ mod tests { max_rows, max_cols, is_loaded: true, + freeze_panes: crate::excel::FreezePanes::none(), } } diff --git a/src/cli/sheet_query.rs b/src/cli/sheet_query.rs index 94cc5ac..54676cd 100644 --- a/src/cli/sheet_query.rs +++ b/src/cli/sheet_query.rs @@ -189,7 +189,7 @@ pub(crate) fn read_header_values( #[cfg(test)] mod tests { use super::*; - use crate::excel::{CellType, Sheet}; + use crate::excel::{CellType, FreezePanes, Sheet}; fn sheet_with_values(name: &str, values: &[&[&str]]) -> Sheet { let max_rows = values.len(); @@ -208,6 +208,7 @@ mod tests { max_rows, max_cols, is_loaded: true, + freeze_panes: FreezePanes::none(), } } diff --git a/src/commands/executor.rs b/src/commands/executor.rs index f49ae8d..58fc44b 100644 --- a/src/commands/executor.rs +++ b/src/commands/executor.rs @@ -2,7 +2,7 @@ use std::path::Path; use crate::app::AppState; use crate::json_export::{export_all_sheets_json, export_json, HeaderDirection}; -use crate::utils::col_name_to_index; +use crate::utils::{cell_reference, col_name_to_index, parse_cell_reference}; impl AppState<'_> { pub fn execute_command(&mut self) { @@ -53,6 +53,8 @@ impl AppState<'_> { "nohlsearch" | "noh" => self.disable_search_highlight(), "help" => self.show_help(), "delsheet" => self.delete_current_sheet(), + "freeze" => self.freeze_at_cell(self.selected_cell), + "unfreeze" => self.clear_freeze_panes(), "addsheet" => self.add_notification("Usage: :addsheet ".to_string()), _ => { // Handle commands with parameters @@ -69,6 +71,8 @@ impl AppState<'_> { self.handle_delete_row_command(&command); } else if command.starts_with("dc") { self.handle_delete_column_command(&command); + } else if let Some(cell_ref) = command.strip_prefix("freeze ") { + self.handle_freeze_command(cell_ref.trim()); } else { self.add_notification(format!("Unknown command: {}", command)); } @@ -76,6 +80,34 @@ impl AppState<'_> { } } + fn handle_freeze_command(&mut self, cell_ref: &str) { + let Some(cell) = parse_cell_reference(cell_ref) else { + self.add_notification("Usage: :freeze [cell]".to_string()); + return; + }; + + self.freeze_at_cell(cell); + } + + fn freeze_at_cell(&mut self, cell: (usize, usize)) { + let (row, col) = cell; + if row == 1 && col == 1 { + self.clear_freeze_panes(); + return; + } + + self.workbook + .set_freeze_panes(row.saturating_sub(1), col.saturating_sub(1)); + self.handle_scrolling(); + self.add_notification(format!("Frozen panes at {}", cell_reference(cell))); + } + + fn clear_freeze_panes(&mut self) { + self.workbook.clear_freeze_panes(); + self.handle_scrolling(); + self.add_notification("Freeze panes cleared".to_string()); + } + fn handle_column_width_command(&mut self, cmd: &str) { let parts: Vec<&str> = cmd.split_whitespace().collect(); @@ -346,14 +378,7 @@ impl AppState<'_> { } self.selected_cell = (row, col); - // Handle scrolling - if self.selected_cell.0 < self.start_row { - self.start_row = self.selected_cell.0; - } else if self.selected_cell.0 >= self.start_row + self.visible_rows { - self.start_row = self.selected_cell.0 - self.visible_rows + 1; - } - - self.ensure_column_visible(self.selected_cell.1); + self.handle_scrolling(); self.add_notification(format!( "Jumped to cell {}{}", @@ -363,37 +388,34 @@ impl AppState<'_> { } } -// Parse a cell reference like "A1", "B10", etc. -fn parse_cell_reference(input: &str) -> Option<(usize, usize)> { - // Cell references should have at least 2 characters (e.g., A1) - if input.chars().count() < 2 { - return None; - } - - // Find the first digit to separate column and row parts - let col_end = input - .char_indices() - .find(|(_, c)| c.is_ascii_digit()) - .map(|(index, _)| index)?; - - if col_end == 0 { - return None; // No digits found - } - - let (col_part, row_part) = input.split_at(col_end); - - // Convert column letters to index - let col = col_name_to_index(&col_part.to_uppercase())?; - - // Parse row number - let row = row_part.parse::().ok()?; - - Some((row, col)) -} - #[cfg(test)] mod tests { use super::parse_cell_reference; + use crate::app::AppState; + use crate::excel::{Cell, FreezePanes, Sheet, Workbook}; + use std::path::PathBuf; + + fn app_with_sheet() -> AppState<'static> { + let mut data = vec![vec![Cell::empty(); 3]; 3]; + data[1][1] = Cell::new("Name".to_string(), false); + data[1][2] = Cell::new("Name".to_string(), false); + data[2][1] = Cell::new("Ada".to_string(), false); + data[2][2] = Cell::new("10".to_string(), false); + let sheet = Sheet { + name: "Data".to_string(), + data, + max_rows: 2, + max_cols: 2, + is_loaded: true, + freeze_panes: FreezePanes::none(), + }; + + AppState::new( + Workbook::from_sheets_for_test(vec![sheet]), + PathBuf::from("test.xlsx"), + ) + .unwrap() + } #[test] fn parses_valid_cell_references() { @@ -406,4 +428,46 @@ mod tests { assert_eq!(parse_cell_reference("addsheet 测试1"), None); assert_eq!(parse_cell_reference("测试1"), None); } + + #[test] + fn freeze_command_uses_current_cell_and_marks_workbook_modified() { + let mut app = app_with_sheet(); + app.selected_cell = (2, 2); + app.input_buffer = "freeze".to_string(); + + app.execute_command(); + + let sheet = app.workbook.get_current_sheet(); + assert_eq!(sheet.freeze_panes.rows, 1); + assert_eq!(sheet.freeze_panes.cols, 1); + assert!(app.workbook.is_modified()); + assert!(app.undo_history.all_undone()); + } + + #[test] + fn freeze_command_accepts_explicit_cell_and_a1_clears() { + let mut app = app_with_sheet(); + + app.input_buffer = "freeze B2".to_string(); + app.execute_command(); + assert_eq!( + app.workbook.get_current_sheet().freeze_panes.split_cell(), + (2, 2) + ); + + app.input_buffer = "freeze A1".to_string(); + app.execute_command(); + assert!(!app.workbook.get_current_sheet().freeze_panes.is_frozen()); + } + + #[test] + fn unfreeze_command_clears_freeze_panes() { + let mut app = app_with_sheet(); + app.workbook.set_freeze_panes(1, 1); + + app.input_buffer = "unfreeze".to_string(); + app.execute_command(); + + assert!(!app.workbook.get_current_sheet().freeze_panes.is_frozen()); + } } diff --git a/src/excel/sheet.rs b/src/excel/sheet.rs index 1ec34ff..efe050b 100644 --- a/src/excel/sheet.rs +++ b/src/excel/sheet.rs @@ -1,4 +1,47 @@ use crate::excel::Cell; +use crate::utils::cell_reference; + +#[derive(Clone)] +pub struct FreezePanes { + pub rows: usize, + pub cols: usize, +} + +impl FreezePanes { + #[must_use] + pub fn none() -> Self { + Self { rows: 0, cols: 0 } + } + + #[must_use] + pub fn from_split_cell(row: usize, col: usize) -> Self { + Self { + rows: row.saturating_sub(1), + cols: col.saturating_sub(1), + } + } + + #[must_use] + pub fn is_frozen(&self) -> bool { + self.rows > 0 || self.cols > 0 + } + + #[must_use] + pub fn split_cell(&self) -> (usize, usize) { + (self.rows + 1, self.cols + 1) + } + + #[must_use] + pub fn split_cell_ref(&self) -> String { + cell_reference(self.split_cell()) + } +} + +impl Default for FreezePanes { + fn default() -> Self { + Self::none() + } +} #[derive(Clone)] pub struct Sheet { @@ -7,6 +50,7 @@ pub struct Sheet { pub max_rows: usize, pub max_cols: usize, pub is_loaded: bool, + pub freeze_panes: FreezePanes, } impl Sheet { @@ -18,6 +62,7 @@ impl Sheet { max_rows: 1, max_cols: 1, is_loaded: true, + freeze_panes: FreezePanes::none(), } } } diff --git a/src/excel/workbook.rs b/src/excel/workbook.rs index 06c341a..7101923 100644 --- a/src/excel/workbook.rs +++ b/src/excel/workbook.rs @@ -6,14 +6,16 @@ use std::io::BufReader; use std::panic::{catch_unwind, AssertUnwindSafe}; use std::path::Path; -use crate::excel::{Cell, CellType, Sheet}; +use crate::excel::{Cell, CellType, FreezePanes, Sheet}; use crate::utils::{index_to_col_name, parse_cell_reference}; mod formula_lookup; +mod freeze_panes; mod save; mod sheet_parse; use formula_lookup::lookup_formula_in_xlsx; +use freeze_panes::lookup_freeze_panes_in_xlsx; use sheet_parse::create_sheet_from_range; pub enum CalamineWorkbook { @@ -90,6 +92,15 @@ fn open_workbook_impl>(path: P, enable_lazy_loading: bool) -> Res .with_context(|| format!("Unable to parse Excel file: {}", path_str))?; let sheet_names = workbook.sheet_names().to_vec(); + let freeze_panes_by_name = sheet_names + .iter() + .map(|name| { + ( + name.clone(), + lookup_freeze_panes_in_xlsx(path_ref, name).unwrap_or_default(), + ) + }) + .collect::>(); // Pre-allocate with the right capacity let mut sheets = Vec::with_capacity(sheet_names.len()); @@ -108,6 +119,7 @@ fn open_workbook_impl>(path: P, enable_lazy_loading: bool) -> Res max_rows: 0, max_cols: 0, is_loaded: false, + freeze_panes: freeze_panes_by_name.get(name).cloned().unwrap_or_default(), }; sheets.push(sheet); @@ -143,6 +155,7 @@ fn open_workbook_impl>(path: P, enable_lazy_loading: bool) -> Res let formula_range = workbook.worksheet_formula(name).ok(); let mut sheet = create_sheet_from_range(name, range, formula_range); sheet.is_loaded = true; + sheet.freeze_panes = freeze_panes_by_name.get(name).cloned().unwrap_or_default(); sheets.push(sheet); } } @@ -170,6 +183,28 @@ fn open_workbook_impl>(path: P, enable_lazy_loading: bool) -> Res }) } +fn shrink_freeze_rows(freeze_panes: &mut FreezePanes, start_row: usize, end_row: usize) -> bool { + if freeze_panes.rows == 0 || start_row > end_row || start_row > freeze_panes.rows { + return false; + } + + let affected_end = end_row.min(freeze_panes.rows); + let deleted_frozen_rows = affected_end - start_row + 1; + freeze_panes.rows = freeze_panes.rows.saturating_sub(deleted_frozen_rows); + true +} + +fn shrink_freeze_cols(freeze_panes: &mut FreezePanes, start_col: usize, end_col: usize) -> bool { + if freeze_panes.cols == 0 || start_col > end_col || start_col > freeze_panes.cols { + return false; + } + + let affected_end = end_col.min(freeze_panes.cols); + let deleted_frozen_cols = affected_end - start_col + 1; + freeze_panes.cols = freeze_panes.cols.saturating_sub(deleted_frozen_cols); + true +} + impl Workbook { pub fn get_current_sheet(&self) -> &Sheet { &self.sheets[self.current_sheet_index] @@ -194,9 +229,11 @@ impl Workbook { match result { Ok(Ok(range)) => { let formula_range = xlsx.worksheet_formula(sheet_name).ok(); + let freeze_panes = self.sheets[sheet_index].freeze_panes.clone(); let mut sheet = create_sheet_from_range(sheet_name, range, formula_range); let original_name = self.sheets[sheet_index].name.clone(); sheet.name = original_name; + sheet.freeze_panes = freeze_panes; self.sheets[sheet_index] = sheet; self.loaded_sheets.insert(sheet_index); } @@ -218,9 +255,11 @@ impl Workbook { match result { Ok(Ok(range)) => { let formula_range = xls.worksheet_formula(sheet_name).ok(); + let freeze_panes = self.sheets[sheet_index].freeze_panes.clone(); let mut sheet = create_sheet_from_range(sheet_name, range, formula_range); let original_name = self.sheets[sheet_index].name.clone(); sheet.name = original_name; + sheet.freeze_panes = freeze_panes; self.sheets[sheet_index] = sheet; self.loaded_sheets.insert(sheet_index); } @@ -472,6 +511,19 @@ impl Workbook { Ok(()) } + pub fn set_freeze_panes(&mut self, rows: usize, cols: usize) { + let sheet = &mut self.sheets[self.current_sheet_index]; + + if sheet.freeze_panes.rows != rows || sheet.freeze_panes.cols != cols { + sheet.freeze_panes = FreezePanes { rows, cols }; + self.is_modified = true; + } + } + + pub fn clear_freeze_panes(&mut self) { + self.set_freeze_panes(0, 0); + } + pub fn get_sheet_names(&self) -> Vec { let mut names = Vec::with_capacity(self.sheets.len()); for sheet in &self.sheets { @@ -545,6 +597,8 @@ impl Workbook { return Ok(()); } + let freeze_changed = shrink_freeze_rows(&mut sheet.freeze_panes, row, row); + // Only remove the row if it exists in the data if row < sheet.data.len() { sheet.data.remove(row); @@ -552,6 +606,10 @@ impl Workbook { self.is_modified = true; } + if freeze_changed { + self.is_modified = true; + } + Ok(()) } @@ -579,6 +637,9 @@ impl Workbook { adjusted_end_row }; + let freeze_changed = + shrink_freeze_rows(&mut sheet.freeze_panes, start_row, effective_end_row); + // Only proceed if there are rows to delete if start_row <= effective_end_row && start_row < sheet.data.len() { // Remove rows in reverse order to avoid index shifting issues @@ -592,6 +653,10 @@ impl Workbook { self.is_modified = true; } + if freeze_changed { + self.is_modified = true; + } + Ok(()) } @@ -608,6 +673,7 @@ impl Workbook { return Ok(()); } + let freeze_changed = shrink_freeze_cols(&mut sheet.freeze_panes, col, col); let mut has_data = false; for row in &sheet.data { if col < row.len() && !row[col].value.is_empty() { @@ -625,7 +691,7 @@ impl Workbook { self.recalculate_max_cols(); self.recalculate_max_rows(); - if has_data { + if has_data || freeze_changed { self.is_modified = true; } @@ -649,6 +715,8 @@ impl Workbook { // If start_col is valid but end_col exceeds max_cols, adjust end_col to max_cols let effective_end_col = end_col.min(sheet.max_cols); + let freeze_changed = + shrink_freeze_cols(&mut sheet.freeze_panes, start_col, effective_end_col); let mut has_data = false; for row in &sheet.data { for col in start_col..=effective_end_col { @@ -673,7 +741,7 @@ impl Workbook { self.recalculate_max_cols(); self.recalculate_max_rows(); - if has_data { + if has_data || freeze_changed { self.is_modified = true; } diff --git a/src/excel/workbook/formula_lookup.rs b/src/excel/workbook/formula_lookup.rs index 5ce8f58..130b388 100644 --- a/src/excel/workbook/formula_lookup.rs +++ b/src/excel/workbook/formula_lookup.rs @@ -76,14 +76,17 @@ pub(super) fn lookup_formula_in_xlsx( None } -fn read_zip_entry(archive: &mut ZipArchive, entry_name: &str) -> Option { +pub(super) fn read_zip_entry( + archive: &mut ZipArchive, + entry_name: &str, +) -> Option { let mut entry = archive.by_name(entry_name).ok()?; let mut contents = String::new(); entry.read_to_string(&mut contents).ok()?; Some(contents) } -fn attr_value( +pub(super) fn attr_value( reader: &quick_xml::Reader<&[u8]>, event: &quick_xml::events::BytesStart<'_>, key: &[u8], @@ -99,7 +102,7 @@ fn attr_value( None } -fn resolve_xlsx_sheet_path( +pub(super) fn resolve_xlsx_sheet_path( archive: &mut ZipArchive, sheet_name: &str, ) -> Option { diff --git a/src/excel/workbook/freeze_panes.rs b/src/excel/workbook/freeze_panes.rs new file mode 100644 index 0000000..0a01e18 --- /dev/null +++ b/src/excel/workbook/freeze_panes.rs @@ -0,0 +1,66 @@ +use quick_xml::events::Event; +use std::fs::File; +use std::path::Path; +use zip::ZipArchive; + +use crate::excel::FreezePanes; + +use super::formula_lookup::{attr_value, read_zip_entry, resolve_xlsx_sheet_path}; + +pub(super) fn lookup_freeze_panes_in_xlsx(file: &Path, sheet_name: &str) -> Option { + let extension = file + .extension() + .and_then(|ext| ext.to_str()) + .map(|ext| ext.to_ascii_lowercase())?; + if extension != "xlsx" && extension != "xlsm" { + return None; + } + + let archive_file = File::open(file).ok()?; + let mut archive = ZipArchive::new(archive_file).ok()?; + let sheet_path = resolve_xlsx_sheet_path(&mut archive, sheet_name)?; + let sheet_xml = read_zip_entry(&mut archive, &sheet_path)?; + + let mut reader = quick_xml::Reader::from_str(&sheet_xml); + reader.config_mut().trim_text(true); + let mut buf = Vec::new(); + + loop { + match reader.read_event_into(&mut buf).ok()? { + Event::Start(event) | Event::Empty(event) if event.name().as_ref() == b"pane" => { + let state = attr_value(&reader, &event, b"state"); + if state.as_deref() != Some("frozen") { + return None; + } + + let rows = attr_value(&reader, &event, b"ySplit") + .and_then(|value| parse_split_count(&value)) + .unwrap_or(0); + let cols = attr_value(&reader, &event, b"xSplit") + .and_then(|value| parse_split_count(&value)) + .unwrap_or(0); + + let panes = FreezePanes { rows, cols }; + return panes.is_frozen().then_some(panes); + } + Event::Eof => break, + _ => {} + } + buf.clear(); + } + + None +} + +fn parse_split_count(value: &str) -> Option { + if let Ok(count) = value.parse::() { + return Some(count); + } + + let numeric = value.parse::().ok()?; + if numeric.fract() == 0.0 && numeric >= 0.0 { + Some(numeric as usize) + } else { + None + } +} diff --git a/src/excel/workbook/save.rs b/src/excel/workbook/save.rs index b135046..12016af 100644 --- a/src/excel/workbook/save.rs +++ b/src/excel/workbook/save.rs @@ -48,6 +48,13 @@ fn write_sheet( ) -> Result<()> { let worksheet = workbook.add_worksheet().set_name(&sheet.name)?; + if sheet.freeze_panes.is_frozen() { + worksheet.set_freeze_panes( + sheet.freeze_panes.rows as u32, + sheet.freeze_panes.cols as u16, + )?; + } + for col in 0..sheet.max_cols { worksheet.set_column_width(col as u16, 15)?; } diff --git a/src/excel/workbook/sheet_parse.rs b/src/excel/workbook/sheet_parse.rs index 2d1268b..fd71053 100644 --- a/src/excel/workbook/sheet_parse.rs +++ b/src/excel/workbook/sheet_parse.rs @@ -1,6 +1,6 @@ use calamine::{Data, Range}; -use crate::excel::{Cell, CellType, DataTypeInfo, Sheet}; +use crate::excel::{Cell, CellType, DataTypeInfo, FreezePanes, Sheet}; pub(super) fn create_sheet_from_range( name: &str, @@ -26,6 +26,7 @@ pub(super) fn create_sheet_from_range( max_rows: height, max_cols: width, is_loaded: true, + freeze_panes: FreezePanes::none(), } } diff --git a/src/excel/workbook/tests.rs b/src/excel/workbook/tests.rs index 3692933..648a687 100644 --- a/src/excel/workbook/tests.rs +++ b/src/excel/workbook/tests.rs @@ -1,7 +1,9 @@ +use std::fs::File; +use std::io::Read; use std::path::{Path, PathBuf}; use super::{open_workbook, Workbook}; -use crate::excel::Sheet; +use crate::excel::{Cell, FreezePanes, Sheet}; fn blank_sheet(name: &str) -> Sheet { Sheet::blank(name.to_string()) @@ -30,6 +32,51 @@ fn create_formula_workbook(path: &Path) { workbook.save(path).unwrap(); } +fn create_freeze_workbook(path: &Path) { + use rust_xlsxwriter::Workbook as XlsxWorkbook; + + let mut workbook = XlsxWorkbook::new(); + let sheet = workbook.add_worksheet(); + sheet.set_name("Frozen").unwrap(); + sheet.set_freeze_panes(1, 1).unwrap(); + sheet.write_string(0, 0, "name").unwrap(); + sheet.write_string(1, 1, "Ada").unwrap(); + workbook.save(path).unwrap(); +} + +fn worksheet_xml(path: &Path, sheet_entry: &str) -> String { + let archive_file = File::open(path).unwrap(); + let mut archive = zip::ZipArchive::new(archive_file).unwrap(); + let mut entry = archive.by_name(sheet_entry).unwrap(); + let mut xml = String::new(); + entry.read_to_string(&mut xml).unwrap(); + xml +} + +fn remove_temp_outputs(prefix: &str) { + for entry in std::fs::read_dir(std::env::temp_dir()).unwrap() { + let entry = entry.unwrap(); + let file_name = entry.file_name(); + let file_name = file_name.to_string_lossy(); + if file_name.starts_with(prefix) && file_name.ends_with(".xlsx") { + let _ = std::fs::remove_file(entry.path()); + } + } +} + +fn find_temp_output(prefix: &str) -> PathBuf { + std::fs::read_dir(std::env::temp_dir()) + .unwrap() + .filter_map(Result::ok) + .map(|entry| entry.path()) + .find(|path| { + path.file_name() + .and_then(|name| name.to_str()) + .is_some_and(|name| name.starts_with(prefix) && name.ends_with(".xlsx")) + }) + .unwrap_or_else(|| panic!("expected saved workbook with prefix {prefix}")) +} + #[test] fn adds_blank_sheet_after_current_sheet() { let mut workbook = @@ -136,3 +183,79 @@ fn formula_for_cell_falls_back_to_xlsx_archive_metadata() { Some("=B2*2".to_string()) ); } + +#[test] +fn open_workbook_restores_xlsx_freeze_panes_metadata() { + let path = temp_path("excel_cli_workbook_freeze_lookup.xlsx"); + create_freeze_workbook(&path); + + let workbook = open_workbook(&path, false).unwrap(); + let sheet = workbook.get_current_sheet(); + + assert_eq!(sheet.freeze_panes.rows, 1); + assert_eq!(sheet.freeze_panes.cols, 1); + assert_eq!(sheet.freeze_panes.split_cell_ref(), "B2"); +} + +#[test] +fn lazy_loaded_sheet_preserves_freeze_panes_after_loading() { + let path = temp_path("excel_cli_workbook_lazy_freeze_lookup.xlsx"); + create_freeze_workbook(&path); + + let mut workbook = open_workbook(&path, true).unwrap(); + let sheet = workbook.get_current_sheet(); + assert!(!sheet.is_loaded); + assert_eq!(sheet.freeze_panes.split_cell_ref(), "B2"); + + workbook.ensure_sheet_loaded(0, "Frozen").unwrap(); + let sheet = workbook.get_current_sheet(); + assert!(sheet.is_loaded); + assert_eq!(sheet.freeze_panes.split_cell_ref(), "B2"); +} + +#[test] +fn save_writes_freeze_panes_to_xlsx_xml() { + let prefix = "excel_cli_freeze_save_"; + remove_temp_outputs(prefix); + + let mut sheet = Sheet::blank("Frozen".to_string()); + sheet.freeze_panes = FreezePanes { rows: 1, cols: 1 }; + let mut workbook = Workbook::from_sheets_for_test(vec![sheet]); + workbook.file_path = temp_path(&format!("{prefix}source.xlsx")) + .to_string_lossy() + .to_string(); + workbook.set_modified(true); + + workbook.save().unwrap(); + + let saved_path = find_temp_output(prefix); + let xml = worksheet_xml(&saved_path, "xl/worksheets/sheet1.xml"); + assert!(xml.contains(r#"xSplit="1""#), "{xml}"); + assert!(xml.contains(r#"ySplit="1""#), "{xml}"); + assert!(xml.contains(r#"topLeftCell="B2""#), "{xml}"); + assert!(xml.contains(r#"state="frozen""#), "{xml}"); +} + +#[test] +fn deleting_rows_and_columns_shrinks_freeze_panes() { + let mut sheet = Sheet::blank("Frozen".to_string()); + sheet.data = vec![vec![Cell::empty(); 5]; 5]; + sheet.data[4][4] = Cell::new("keep bounds".to_string(), false); + sheet.max_rows = 4; + sheet.max_cols = 4; + sheet.freeze_panes = FreezePanes { rows: 2, cols: 2 }; + let mut workbook = Workbook::from_sheets_for_test(vec![sheet]); + + workbook.delete_row(1).unwrap(); + assert_eq!(workbook.get_current_sheet().freeze_panes.rows, 1); + assert_eq!(workbook.get_current_sheet().freeze_panes.cols, 2); + + workbook.delete_column(2).unwrap(); + assert_eq!(workbook.get_current_sheet().freeze_panes.rows, 1); + assert_eq!(workbook.get_current_sheet().freeze_panes.cols, 1); + + workbook.delete_row(10).unwrap(); + workbook.delete_column(10).unwrap(); + assert_eq!(workbook.get_current_sheet().freeze_panes.rows, 1); + assert_eq!(workbook.get_current_sheet().freeze_panes.cols, 1); +} diff --git a/src/ui/handlers.rs b/src/ui/handlers.rs index 11058d7..4a56686 100644 --- a/src/ui/handlers.rs +++ b/src/ui/handlers.rs @@ -399,7 +399,7 @@ mod tests { use super::handle_key_event; use crate::app::{AppState, InputMode}; - use crate::excel::{Cell, Sheet, Workbook}; + use crate::excel::{Cell, FreezePanes, Sheet, Workbook}; fn app_with_sheet() -> AppState<'static> { let mut data = vec![vec![Cell::empty(); 3]; 3]; @@ -413,6 +413,7 @@ mod tests { max_rows: 2, max_cols: 2, is_loaded: true, + freeze_panes: FreezePanes::none(), }; let app = AppState::new( Workbook::from_sheets_for_test(vec![sheet]), diff --git a/src/ui/render/spreadsheet.rs b/src/ui/render/spreadsheet.rs index 21bc98d..7c6241b 100644 --- a/src/ui/render/spreadsheet.rs +++ b/src/ui/render/spreadsheet.rs @@ -28,7 +28,7 @@ pub(super) fn update_visible_area(app_state: &mut AppState, area: Rect) { // Calculate available width for columns (subtract row numbers and borders) let available_width = data_columns_available_width(app_state, area); ensure_selected_column_fully_visible(app_state, available_width); - let visible_cols = visible_data_column_widths(app_state, available_width).len(); + let visible_cols = visible_data_columns(app_state, available_width).len(); // Ensure at least one column is visible app_state.visible_cols = visible_cols.max(1); @@ -40,9 +40,16 @@ fn data_columns_available_width(app_state: &AppState, area: Rect) -> usize { fn ensure_selected_column_fully_visible(app_state: &mut AppState, available_width: usize) { let selected_col = app_state.selected_cell.1; + let frozen_cols = app_state.workbook.get_current_sheet().freeze_panes.cols; + + if selected_col <= frozen_cols { + return; + } + + let scroll_start_min = frozen_cols + 1; if selected_col < app_state.start_col { - app_state.start_col = selected_col; + app_state.start_col = selected_col.max(scroll_start_min); } while app_state.start_col < selected_col @@ -50,6 +57,10 @@ fn ensure_selected_column_fully_visible(app_state: &mut AppState, available_widt { app_state.start_col += 1; } + + if app_state.start_col < scroll_start_min { + app_state.start_col = scroll_start_min; + } } fn columns_width(app_state: &AppState, start_col: usize, end_col: usize) -> usize { @@ -61,60 +72,121 @@ fn columns_width(app_state: &AppState, start_col: usize, end_col: usize) -> usiz content_width + TABLE_COLUMN_SPACING * col_count.saturating_sub(1) } -fn visible_data_column_widths(app_state: &AppState, available_width: usize) -> Vec { +fn visible_data_columns(app_state: &AppState, available_width: usize) -> Vec<(usize, usize)> { let sheet = app_state.workbook.get_current_sheet(); - let max_col = sheet.max_cols.max(app_state.start_col); - let mut widths = Vec::new(); - let mut width_used = 0; + let frozen_cols = sheet.freeze_panes.cols.min(sheet.max_cols); + let scroll_start = app_state.start_col.max(frozen_cols + 1); + let max_col = sheet.max_cols.max(scroll_start); + let has_scroll_cols = scroll_start <= max_col; + let frozen_available_width = if has_scroll_cols && available_width > 1 { + available_width - 1 + } else { + available_width + }; - for col_idx in app_state.start_col..=max_col { - let col_width = app_state.get_column_width(col_idx); - let spacing = if widths.is_empty() { - 0 - } else { - TABLE_COLUMN_SPACING - }; + let mut columns = Vec::new(); + let mut width_used = 0; - if width_used + spacing >= available_width { + for col_idx in 1..=frozen_cols { + if !push_visible_column( + app_state, + &mut columns, + &mut width_used, + col_idx, + frozen_available_width, + ) { break; } + } - let remaining_width = available_width - width_used - spacing; - let render_width = col_width.min(remaining_width); - widths.push(render_width); - width_used += spacing + render_width; - - if render_width < col_width { + for col_idx in scroll_start..=max_col { + if !push_visible_column( + app_state, + &mut columns, + &mut width_used, + col_idx, + available_width, + ) { break; } } - if widths.is_empty() { - widths.push( + if columns.is_empty() { + columns.push(( + scroll_start, app_state - .get_column_width(app_state.start_col) + .get_column_width(scroll_start) .min(available_width), - ); + )); + } + + columns +} + +fn push_visible_column( + app_state: &AppState, + columns: &mut Vec<(usize, usize)>, + width_used: &mut usize, + col_idx: usize, + available_width: usize, +) -> bool { + let col_width = app_state.get_column_width(col_idx); + let spacing = if columns.is_empty() { + 0 + } else { + TABLE_COLUMN_SPACING + }; + + if *width_used + spacing >= available_width { + return false; } - widths + let remaining_width = available_width - *width_used - spacing; + let render_width = col_width.min(remaining_width); + columns.push((col_idx, render_width)); + *width_used += spacing + render_width; + + render_width == col_width +} + +fn visible_data_rows(app_state: &AppState) -> Vec { + let sheet = app_state.workbook.get_current_sheet(); + let max_row = sheet.max_rows.max(app_state.start_row); + let frozen_rows = sheet.freeze_panes.rows.min(sheet.max_rows); + let scroll_start = app_state.start_row.max(frozen_rows + 1); + let has_scroll_rows = scroll_start <= max_row; + let available_rows = app_state.visible_rows; + let frozen_rows_visible = if has_scroll_rows && available_rows > 1 { + frozen_rows.min(available_rows - 1) + } else { + frozen_rows.min(available_rows) + }; + + let mut rows = Vec::with_capacity(available_rows); + rows.extend(1..=frozen_rows_visible); + + let scroll_rows_available = available_rows.saturating_sub(rows.len()); + rows.extend((scroll_start..=max_row).take(scroll_rows_available)); + + if rows.is_empty() && available_rows > 0 { + rows.push(scroll_start); + } + + rows } pub(super) fn draw_spreadsheet(f: &mut Frame, app_state: &AppState, area: Rect) { // Calculate visible row and column ranges - let start_row = app_state.start_row; - let end_row = start_row + app_state.visible_rows - 1; - let start_col = app_state.start_col; - let data_column_widths = - visible_data_column_widths(app_state, data_columns_available_width(app_state, area)); - let visible_cols = data_column_widths.len().max(1); - let end_col = start_col + visible_cols - 1; + let data_columns = + visible_data_columns(app_state, data_columns_available_width(app_state, area)); + let visible_rows = visible_data_rows(app_state); + let visible_cols = data_columns.len().max(1); let mut constraints = Vec::with_capacity(visible_cols + 1); constraints.push(Constraint::Length(app_state.row_number_width as u16)); // Dynamic row header width - for width in data_column_widths { - constraints.push(Constraint::Length(width as u16)); + for (_, width) in &data_columns { + constraints.push(Constraint::Length(*width as u16)); } // Set table style based on current mode @@ -141,27 +213,43 @@ pub(super) fn draw_spreadsheet(f: &mut Frame, app_state: &AppState, area: Rect) } else { theme::base() }; + let sheet = app_state.workbook.get_current_sheet(); + let frozen_rows = sheet.freeze_panes.rows.min(sheet.max_rows); + let frozen_cols = sheet.freeze_panes.cols.min(sheet.max_cols); // Create header row let mut header_cells = Vec::with_capacity(app_state.visible_cols + 1); - header_cells.push(Cell::from("").style(header_style)); + header_cells.push(Cell::from("").style(frozen_header_style( + header_style, + is_editing, + frozen_rows > 0 || frozen_cols > 0, + ))); // Add column headers - for col in start_col..=end_col { - let col_name = index_to_col_name(col); - header_cells.push(Cell::from(col_name).style(header_style)); + for (col, _) in &data_columns { + let col_name = index_to_col_name(*col); + header_cells.push(Cell::from(col_name).style(frozen_header_style( + header_style, + is_editing, + *col <= frozen_cols, + ))); } let header = Row::new(header_cells).height(1); // Create data rows - let rows = (start_row..=end_row).map(|row| { + let rows = visible_rows.into_iter().map(|row| { let mut cells = Vec::with_capacity(app_state.visible_cols + 1); // Add row header - cells.push(Cell::from(row.to_string()).style(header_style)); + cells.push(Cell::from(row.to_string()).style(frozen_header_style( + header_style, + is_editing, + row <= frozen_rows, + ))); // Add cells for this row - for col in start_col..=end_col { + for (col, _) in &data_columns { + let col = *col; let content = if app_state.selected_cell == (row, col) && matches!(app_state.input_mode, InputMode::Editing) { @@ -236,6 +324,8 @@ pub(super) fn draw_spreadsheet(f: &mut Frame, app_state: &AppState, area: Rect) } else if app_state.highlight_enabled && app_state.search_results.contains(&(row, col)) { Style::default().bg(theme::SEARCH).fg(Color::Black) + } else if row <= frozen_rows || col <= frozen_cols { + frozen_cell_style(is_editing) } else { cell_style }; @@ -259,6 +349,30 @@ pub(super) fn draw_spreadsheet(f: &mut Frame, app_state: &AppState, area: Rect) f.render_widget(table, area); } +fn frozen_cell_style(is_editing: bool) -> Style { + let foreground = if is_editing { + theme::TEXT_DISABLED + } else { + theme::TEXT + }; + + Style::default().bg(theme::FROZEN_BACKGROUND).fg(foreground) +} + +fn frozen_header_style(base_style: Style, is_editing: bool, is_frozen: bool) -> Style { + if !is_frozen { + return base_style; + } + + let foreground = if is_editing { + theme::TEXT_DISABLED + } else { + theme::TEXT_SECONDARY + }; + + Style::default().bg(theme::FROZEN_BACKGROUND).fg(foreground) +} + pub(super) fn draw_title_with_tabs(f: &mut Frame, app_state: &AppState, area: Rect) { let is_editing = matches!(app_state.input_mode, InputMode::Editing); let sheet_names = app_state.workbook.get_sheet_names(); @@ -417,5 +531,14 @@ pub(super) fn draw_title_with_tabs(f: &mut Frame, app_state: &AppState, area: Re fn sheet_rows_cols(app_state: &AppState) -> String { let sheet = app_state.workbook.get_current_sheet(); - format!("{} x {}", sheet.max_rows, sheet.max_cols) + if sheet.freeze_panes.is_frozen() { + format!( + "{} x {} Frozen: {}", + sheet.max_rows, + sheet.max_cols, + sheet.freeze_panes.split_cell_ref() + ) + } else { + format!("{} x {}", sheet.max_rows, sheet.max_cols) + } } diff --git a/src/ui/render/status.rs b/src/ui/render/status.rs index 6835b5e..f21fad6 100644 --- a/src/ui/render/status.rs +++ b/src/ui/render/status.rs @@ -122,9 +122,11 @@ fn parse_command(input: &str) -> Vec> { "help", "addsheet", "delsheet", + "freeze", + "unfreeze", ]; - let commands_with_params = ["cw", "ej", "eja", "sheet", "dr", "dc", "addsheet"]; + let commands_with_params = ["cw", "ej", "eja", "sheet", "dr", "dc", "addsheet", "freeze"]; let special_keywords = ["fit", "min", "all", "h", "v", "horizontal", "vertical"]; diff --git a/src/ui/render/tests.rs b/src/ui/render/tests.rs index ff86b75..c612cb2 100644 --- a/src/ui/render/tests.rs +++ b/src/ui/render/tests.rs @@ -3,7 +3,7 @@ use std::path::PathBuf; use super::{theme, ui}; use crate::app::{AppState, HelpEntry, InputMode}; -use crate::excel::{Cell, Sheet, Workbook}; +use crate::excel::{Cell, FreezePanes, Sheet, Workbook}; fn app_with_sheet() -> AppState<'static> { let mut data = vec![vec![Cell::empty(); 3]; 3]; @@ -18,6 +18,7 @@ fn app_with_sheet() -> AppState<'static> { max_rows: 2, max_cols: 2, is_loaded: true, + freeze_panes: FreezePanes::none(), }; let app = AppState::new( Workbook::from_sheets_for_test(vec![sheet]), @@ -34,6 +35,7 @@ fn app_with_many_sheets() -> AppState<'static> { max_rows: 1, max_cols: 1, is_loaded: true, + freeze_panes: FreezePanes::none(), }; AppState::new( @@ -86,6 +88,7 @@ fn app_with_long_c22_cell() -> AppState<'static> { max_rows: 23, max_cols: 4, is_loaded: true, + freeze_panes: FreezePanes::none(), }; AppState::new( @@ -95,6 +98,30 @@ fn app_with_long_c22_cell() -> AppState<'static> { .unwrap() } +fn app_with_frozen_grid() -> AppState<'static> { + let mut data = vec![vec![Cell::empty(); 9]; 9]; + for (row_idx, row) in data.iter_mut().enumerate().take(9).skip(1) { + for (col_idx, cell) in row.iter_mut().enumerate().take(9).skip(1) { + *cell = Cell::new(format!("R{row_idx}C{col_idx}"), false); + } + } + + let sheet = Sheet { + name: "Frozen".to_string(), + data, + max_rows: 8, + max_cols: 8, + is_loaded: true, + freeze_panes: FreezePanes { rows: 1, cols: 1 }, + }; + + AppState::new( + Workbook::from_sheets_for_test(vec![sheet]), + PathBuf::from("frozen.xlsx"), + ) + .unwrap() +} + fn rendered_lines(terminal: &Terminal) -> Vec { let buffer = terminal.backend().buffer(); let width = buffer.area.width as usize; @@ -121,6 +148,21 @@ fn text_fg_at(terminal: &Terminal, needle: &str) -> Color { buffer.content[row * width + col + offset].fg } +fn text_bg_at(terminal: &Terminal, needle: &str) -> Color { + let lines = rendered_lines(terminal); + let row = line_index(&lines, needle); + let col = lines[row] + .find(needle) + .unwrap_or_else(|| panic!("expected rendered output to contain {needle}")); + let offset = needle + .chars() + .position(|ch| !ch.is_whitespace()) + .unwrap_or(0); + let buffer = terminal.backend().buffer(); + let width = buffer.area.width as usize; + buffer.content[row * width + col + offset].bg +} + fn fg_at(terminal: &Terminal, row: usize, col: usize) -> Color { let buffer = terminal.backend().buffer(); let width = buffer.area.width as usize; @@ -162,6 +204,58 @@ fn auto_fit_all_renders_full_long_cell_content() { assert!(row.contains(expected), "{row}"); } +#[test] +fn frozen_panes_keep_top_row_and_left_column_visible_while_scrolled() { + let backend = TestBackend::new(100, 32); + let mut terminal = Terminal::new(backend).unwrap(); + let mut app = app_with_frozen_grid(); + app.start_row = 6; + app.start_col = 6; + app.selected_cell = (6, 6); + + terminal.draw(|frame| ui(frame, &mut app)).unwrap(); + + let rendered = rendered_lines(&terminal).join("\n"); + assert!(rendered.contains("Frozen: B2"), "{rendered}"); + assert!(rendered.contains("R1C1"), "{rendered}"); + assert!(rendered.contains("R1C6"), "{rendered}"); + assert!(rendered.contains("R6C1"), "{rendered}"); + assert!(rendered.contains("R6C6"), "{rendered}"); +} + +#[test] +fn frozen_panes_style_frozen_regions_while_scrolled() { + let backend = TestBackend::new(100, 32); + let mut terminal = Terminal::new(backend).unwrap(); + let mut app = app_with_frozen_grid(); + app.start_row = 6; + app.start_col = 6; + app.selected_cell = (8, 8); + + terminal.draw(|frame| ui(frame, &mut app)).unwrap(); + + assert_eq!(text_bg_at(&terminal, "R1C1"), theme::FROZEN_BACKGROUND); + assert_eq!(text_bg_at(&terminal, "R1C6"), theme::FROZEN_BACKGROUND); + assert_eq!(text_bg_at(&terminal, "R6C1"), theme::FROZEN_BACKGROUND); + assert_eq!(text_bg_at(&terminal, "R6C6"), theme::BACKGROUND); +} + +#[test] +fn selected_and_search_styles_override_frozen_region_style() { + let backend = TestBackend::new(100, 32); + let mut terminal = Terminal::new(backend).unwrap(); + let mut app = app_with_frozen_grid(); + app.start_row = 6; + app.start_col = 6; + app.selected_cell = (1, 1); + app.search_results.push((1, 6)); + + terminal.draw(|frame| ui(frame, &mut app)).unwrap(); + + assert_eq!(text_bg_at(&terminal, "R1C1"), Color::White); + assert_eq!(text_bg_at(&terminal, "R1C6"), theme::SEARCH); +} + #[test] fn auto_fit_all_does_not_shrink_visible_fitted_columns() { let backend = TestBackend::new(148, 59); diff --git a/src/ui/theme.rs b/src/ui/theme.rs index 5d1f95b..83eb4f6 100644 --- a/src/ui/theme.rs +++ b/src/ui/theme.rs @@ -4,6 +4,7 @@ pub const BACKGROUND: Color = Color::Rgb(11, 16, 32); pub const SURFACE: Color = Color::Rgb(17, 24, 39); pub const SURFACE_MUTED: Color = Color::Rgb(31, 41, 55); pub const GRID: Color = Color::Rgb(55, 65, 81); +pub const FROZEN_BACKGROUND: Color = Color::Rgb(20, 54, 78); pub const TEXT: Color = Color::Rgb(229, 231, 235); pub const TEXT_SECONDARY: Color = Color::Rgb(156, 163, 175); pub const TEXT_DISABLED: Color = Color::Rgb(107, 114, 128); diff --git a/src/utils/helpers.rs b/src/utils/helpers.rs index 13b3182..038c7c0 100644 --- a/src/utils/helpers.rs +++ b/src/utils/helpers.rs @@ -47,8 +47,9 @@ pub fn parse_cell_reference(reference: &str) -> Option<(usize, usize)> { } let first_digit_pos = reference - .chars() - .position(|c| c.is_ascii_digit()) + .char_indices() + .find(|(_, c)| c.is_ascii_digit()) + .map(|(index, _)| index) .unwrap_or(reference.len()); if first_digit_pos == 0 || first_digit_pos == reference.len() {