From d7174f829790e3eac2accc3aa3c7bb96961d14cf Mon Sep 17 00:00:00 2001 From: DWAA1660 Date: Fri, 9 Oct 2026 14:33:43 -0600 Subject: [PATCH] M2: preserve inherited formatting when entering empty cells (#70) Resolve input styles from the destination cell, row or column before parsing. Preserve inherited alignment, borders and number formats for single-cell and bulk entry, including text paste and array formulas. Co-authored-by: Codex --- crates/engine/src/cmd/data.rs | 3 +- crates/engine/src/cmd/edit.rs | 31 +++++++------- crates/engine/src/cmd/whatif.rs | 3 +- crates/engine/src/tests.rs | 71 +++++++++++++++++++++++++++++++++ 4 files changed, 87 insertions(+), 21 deletions(-) diff --git a/crates/engine/src/cmd/data.rs b/crates/engine/src/cmd/data.rs index 8961d8769..1a7ec859c 100644 --- a/crates/engine/src/cmd/data.rs +++ b/crates/engine/src/cmd/data.rs @@ -662,8 +662,7 @@ fn text_to_columns(s: &mut Session, p: &Json) -> Result { } } for (c, t) in out { - let old = cx.wb.sheet(sheet).and_then(|sh| sh.cell(c)).cloned(); - let cell = super::edit::input_to_cell(&t, old.as_ref(), &mut cx.wb).unwrap_or(None); + let cell = super::edit::input_to_cell(&t, sheet, c, &mut cx.wb).unwrap_or(None); let shm = cx.sheet_mut(sheet)?; match cell { Some(x) => shm.cells.set(c, x), diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index cffe15430..3660de8a5 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -119,9 +119,10 @@ pub fn specs() -> Vec { // ---------------------------------------------------------------- entry /// What typing `input` into a cell produces (constant or formula, plus an automatic number -/// format). Errors when the formula can't be parsed. -pub(crate) fn input_to_cell(input: &str, old: Option<&Cell>, wb: &mut gridcraft_model::Workbook) -> Result> { - let style = old.map(|c| c.style).unwrap_or_default(); +/// format). Inherits the destination's cell, row or column style, including its number +/// format, before parsing. Errors when the formula can't be parsed. +pub(crate) fn input_to_cell(input: &str, sheet: usize, at: CellRef, wb: &mut gridcraft_model::Workbook) -> Result> { + let style = wb.sheet(sheet).map(|sh| sh.style_id(at)).unwrap_or_default(); if input.is_empty() { let c = Cell { value: Value::Empty, formula: None, style }; return Ok(if c.is_blank() { None } else { Some(c) }); @@ -206,8 +207,7 @@ fn cell_set(s: &mut Session, p: &Json) -> Result { if array { // Legacy Ctrl+Shift+Enter array formula over the selection. let range = cx.sel.current(); - let old = cx.wb.sheet(sheet).and_then(|sh| sh.cell(range.start)).cloned(); - let mut cell = input_to_cell(&input, old.as_ref(), &mut cx.wb)?.unwrap_or_default(); + let mut cell = input_to_cell(&input, sheet, range.start, &mut cx.wb)?.unwrap_or_default(); if let Some(f) = cell.formula.as_mut() { std::sync::Arc::make_mut(f).array = Some(range); } @@ -216,8 +216,7 @@ fn cell_set(s: &mut Session, p: &Json) -> Result { cx.touch(sheet, range.start); return Ok(Json::Null); } - let old = cx.wb.sheet(sheet).and_then(|sh| sh.cell(at)).cloned(); - let cell = input_to_cell(&input, old.as_ref(), &mut cx.wb)?; + let cell = input_to_cell(&input, sheet, at, &mut cx.wb)?; let sh = cx.sheet_mut(sheet)?; match cell { Some(c) => sh.cells.set(at, c), @@ -265,15 +264,16 @@ fn range_set_values(s: &mut Session, p: &Json) -> Result { for (ci, v) in cols.iter().enumerate() { let Some(at) = start.offset(ri as i64, ci as i64) else { continue }; let input = json_to_input(v); - let old = cx.wb.sheet(sheet).and_then(|sh| sh.cell(at)).cloned(); let cell = match v { Json::Number(n) => Some(Cell { value: Value::number(n.as_f64().unwrap_or(0.0)), formula: None, - style: old.as_ref().map(|c| c.style).unwrap_or_default(), + style: cx.wb.sheet(sheet).map(|sh| sh.style_id(at)).unwrap_or_default(), }), - Json::Bool(b) => Some(Cell { value: Value::Bool(*b), formula: None, style: old.as_ref().map(|c| c.style).unwrap_or_default() }), - _ => input_to_cell(&input, old.as_ref(), &mut cx.wb)?, + Json::Bool(b) => { + Some(Cell { value: Value::Bool(*b), formula: None, style: cx.wb.sheet(sheet).map(|sh| sh.style_id(at)).unwrap_or_default() }) + } + _ => input_to_cell(&input, sheet, at, &mut cx.wb)?, }; let sh = cx.sheet_mut(sheet)?; match cell { @@ -297,8 +297,7 @@ fn range_fill(s: &mut Session, p: &Json) -> Result { let input = str_param(p, "input").unwrap_or("").to_string(); let origin = s.doc()?.selection.active; edit(s, |cx| { - let old = cx.wb.sheet(sheet).and_then(|sh| sh.cell(origin)).cloned(); - let base = input_to_cell(&input, old.as_ref(), &mut cx.wb)?; + let base = input_to_cell(&input, sheet, origin, &mut cx.wb)?; for r in &ranges { if r.count() > 2_000_000 { return Err(bad("range.fill", "range too large")); @@ -665,8 +664,7 @@ fn paste_text(s: &mut Session, p: &Json, text: &str) -> Result { for (ri, row) in rows.iter().enumerate() { for (ci, v) in row.iter().enumerate() { let Some(c) = at.offset(ri as i64, ci as i64) else { continue }; - let old = cx.wb.sheet(sheet).and_then(|sh| sh.cell(c)).cloned(); - let cell = input_to_cell(v, old.as_ref(), &mut cx.wb).unwrap_or_else(|_| Some(Cell::value(Value::text(v.as_str())))); + let cell = input_to_cell(v, sheet, c, &mut cx.wb).unwrap_or_else(|_| Some(Cell::value(Value::text(v.as_str())))); let sh = cx.sheet_mut(sheet)?; match cell { Some(cell) => sh.cells.set(c, cell), @@ -1234,8 +1232,7 @@ fn replace(s: &mut Session, p: &Json) -> Result { } else { replace_ci(&text, &what, &with) }; - let old = cx.wb.sheet(si).and_then(|sh| sh.cell(c)).cloned(); - let cell = input_to_cell(&new, old.as_ref(), &mut cx.wb).unwrap_or_else(|_| Some(Cell::value(Value::text(new.as_str())))); + let cell = input_to_cell(&new, si, c, &mut cx.wb).unwrap_or_else(|_| Some(Cell::value(Value::text(new.as_str())))); let sh = cx.sheet_mut(si)?; match cell { Some(cell) => sh.cells.set(c, cell), diff --git a/crates/engine/src/cmd/whatif.rs b/crates/engine/src/cmd/whatif.rs index 111225de0..c0a61df64 100644 --- a/crates/engine/src/cmd/whatif.rs +++ b/crates/engine/src/cmd/whatif.rs @@ -489,8 +489,7 @@ fn scenario_show(s: &mut Session, p: &Json) -> Result { edit(s, |cx| { for (c, v) in cells.iter().zip(sc.values.iter()) { let input = super::edit::json_to_input(v); - let old = cx.wb.sheet(si).and_then(|sh| sh.cell(*c)).cloned(); - let cell = super::edit::input_to_cell(&input, old.as_ref(), &mut cx.wb)?; + let cell = super::edit::input_to_cell(&input, si, *c, &mut cx.wb)?; let sh = cx.sheet_mut(si)?; match cell { Some(cell) => sh.cells.set(*c, cell), diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index f3e36cdf4..443588b65 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -13,6 +13,77 @@ fn v(s: &Session, a: &str) -> Value { s.doc().unwrap().wb.active().unwrap().value(CellRef::parse(a).unwrap()) } +#[test] +fn entering_empty_cells_preserves_whole_sheet_formatting() { + let mut s = s(); + s.execute("cell.set", json!({"cell": "A1", "input": "existing"})).unwrap(); + s.execute("selection.set", json!({"range": "A1:XFD1048576"})).unwrap(); + s.execute("home.alignCenter", json!({})).unwrap(); + let border = gridcraft_model::BorderLine { style: gridcraft_model::BorderStyle::Thin, color: gridcraft_model::Color::Auto }; + s.execute("home.formatCells", json!({"style": {"borders": {"top": border, "bottom": border, "left": border, "right": border}}})).unwrap(); + let expected = { + let d = s.doc().unwrap(); + d.wb.styles.get(d.wb.active().unwrap().style_id(CellRef::parse("B1").unwrap())).clone() + }; + assert_eq!(expected.align.h, gridcraft_model::HAlign::Center); + for (cell, input) in [("A1", "edited"), ("B1", "new"), ("C1", "=1+2"), ("XFD1048576", "edge")] { + s.execute("cell.set", json!({"cell": cell, "input": input})).unwrap(); + let d = s.doc().unwrap(); + assert_eq!(d.wb.styles.get(d.wb.active().unwrap().style_id(CellRef::parse(cell).unwrap())), &expected, "{cell}"); + } + s.execute("range.setValues", json!({"range": "D1", "values": [["text", 7, true]]})).unwrap(); + for cell in ["D1", "E1", "F1"] { + let d = s.doc().unwrap(); + assert_eq!(d.wb.styles.get(d.wb.active().unwrap().style_id(CellRef::parse(cell).unwrap())), &expected, "{cell}"); + } + s.execute("edit.undo", json!({})).unwrap(); + assert!(v(&s, "D1").is_empty()); + s.execute("edit.redo", json!({})).unwrap(); + let d = s.doc().unwrap(); + assert_eq!(d.wb.styles.get(d.wb.active().unwrap().style_id(CellRef::parse("D1").unwrap())), &expected); + assert_eq!(d.wb.active().unwrap().cells.len(), 7); + let (reopened, _) = gridcraft_xlsx::read_xlsx(&gridcraft_xlsx::write_xlsx(&d.wb).unwrap()).unwrap(); + for cell in ["A1", "B1", "C1", "D1", "E1", "F1", "G1", "XFD1048576"] { + assert_eq!( + reopened.styles.get(reopened.active().unwrap().style_id(CellRef::parse(cell).unwrap())), + &expected, + "{cell} after XLSX round trip" + ); + } +} + +#[test] +fn entry_and_text_paste_use_inherited_formats_and_cell_overrides() { + let mut s = s(); + s.execute("home.bold", json!({"range": "B:B", "on": true})).unwrap(); + s.execute("home.formatCells", json!({"range": "3:3", "style": {"num_fmt": "@", "font": {"italic": true}}})).unwrap(); + s.execute("home.fontColor", json!({"range": "B3", "color": "#FF0000"})).unwrap(); + let expected = { + let d = s.doc().unwrap(); + d.wb.styles.get(d.wb.active().unwrap().style_id(CellRef::parse("B3").unwrap())).clone() + }; + s.execute("cell.set", json!({"cell": "B3", "input": "=1+2"})).unwrap(); + s.execute("cell.set", json!({"cell": "C3", "input": "00123"})).unwrap(); + s.execute("edit.paste", json!({"at": "D3", "text": "=2+3\t00456"})).unwrap(); + assert_eq!(v(&s, "B3"), Value::text("=1+2")); + assert_eq!(v(&s, "C3"), Value::text("00123")); + assert_eq!(v(&s, "D3"), Value::text("=2+3")); + assert_eq!(v(&s, "E3"), Value::text("00456")); + s.execute("cell.set", json!({"cell": "B4", "input": "12%"})).unwrap(); + let d = s.doc().unwrap(); + let sh = d.wb.active().unwrap(); + assert_eq!(d.wb.styles.get(sh.style_id(CellRef::parse("B3").unwrap())), &expected); + for cell in ["C3", "D3", "E3"] { + let st = d.wb.styles.get(sh.style_id(CellRef::parse(cell).unwrap())); + assert!(st.font.italic); + assert!(!st.font.bold, "row style takes precedence over column style"); + assert_eq!(st.num_fmt.as_str(), "@"); + } + let percent = d.wb.styles.get(sh.style_id(CellRef::parse("B4").unwrap())); + assert!(percent.font.bold); + assert_ne!(percent.num_fmt.as_str(), "General"); +} + #[test] fn enter_values_and_formulas() { let mut s = s();