diff --git a/crates/calc/src/recalc.rs b/crates/calc/src/recalc.rs index 13b47c41c..f285fad67 100644 --- a/crates/calc/src/recalc.rs +++ b/crates/calc/src/recalc.rs @@ -204,6 +204,12 @@ impl PassHost<'_> { let mut ev = Evaluator::new(self, k.0, k.1); ev.value(&expr) }; + // A legacy array formula fills the range it was entered in. + let legacy = self.wb.sheet(k.0).and_then(|s| s.cell(k.1)).and_then(|c| c.formula.as_ref()).and_then(|f| f.array); + let v = match legacy { + Some(range) => fit_to_range(v, range), + None => v, + }; self.depth -= 1; self.in_progress.remove(&k); self.pending.remove(&k); @@ -221,6 +227,30 @@ impl PassHost<'_> { } } +/// A legacy (Ctrl+Shift+Enter) array formula's result fitted to the range it was entered in, as +/// Excel does: a single row or column repeats across the range, a single value fills it, cells +/// past the result show `#N/A`, and a result larger than the range is cut off. +fn fit_to_range(v: Value, range: RangeRef) -> Value { + if range.count() > crate::eval::MAX_CELLS { + return Value::Error(CellError::Num); + } + let (h, w) = (range.height() as usize, range.width() as usize); + let Value::Array(a) = v else { + return if h * w == 1 { v } else { Array::new(h, w, vec![v; h * w]).map(Value::from).unwrap_or(Value::Error(CellError::Value)) }; + }; + if h * w == 1 { + return a.data.first().cloned().unwrap_or_default(); + } + let mut data = Vec::with_capacity(h * w); + for r in 0..h { + for c in 0..w { + let (r, c) = (if a.rows == 1 { 0 } else { r }, if a.cols == 1 { 0 } else { c }); + data.push(a.get(r, c).cloned().unwrap_or(Value::Error(CellError::NA))); + } + } + Array::new(h, w, data).map(Value::from).unwrap_or(Value::Error(CellError::Value)) +} + impl Host for PassHost<'_> { fn workbook(&self) -> &Workbook { self.wb diff --git a/crates/calc/src/tests.rs b/crates/calc/src/tests.rs index 67db0830d..9d1994f9c 100644 --- a/crates/calc/src/tests.rs +++ b/crates/calc/src/tests.rs @@ -185,6 +185,33 @@ fn names_and_sheets() { assert_eq!(t.num("A1"), 5.0); } +#[test] +fn legacy_array_formulas_fill_their_range() { + let mut t = T::new(); + t.set("A1", "1"); + t.set("A2", "2"); + let cse = |t: &mut T, at: &str, formula: &str, range: &str| { + let mut f = Formula::new(formula); + f.array = gridcraft_core::RangeRef::parse(range); + t.wb.sheet_mut(0).unwrap().cells.set(c(at), Cell::formula(f)); + t.calc.cells_changed(&mut t.wb, &[(0, c(at))]); + }; + // Past the result: #N/A. A single column repeats across the range. + cse(&mut t, "C1", "=A1:A2*2", "C1:D3"); + cse(&mut t, "F1", "=A1:A2*2", "F1"); + cse(&mut t, "G1", "=5", "G1:G2"); + assert_eq!(t.num("C2"), 4.0); + assert_eq!(t.num("D2"), 4.0); + assert_eq!(t.get("C3"), Value::Error(CellError::NA)); + // A smaller range shows only part of the result and never spills. + assert_eq!(t.num("F1"), 2.0); + assert_eq!(t.get("F2"), Value::Empty); + assert_eq!(t.num("G2"), 5.0); + // A range too large to fill is an error, not an allocation of every cell. + cse(&mut t, "H1", "=1", "H1:XFD1048576"); + assert_eq!(t.get("H1"), Value::Error(CellError::Num)); +} + #[test] fn text_function() { let mut t = T::new(); diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index cffe15430..3e1008254 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -212,6 +212,13 @@ fn cell_set(s: &mut Session, p: &Json) -> Result { std::sync::Arc::make_mut(f).array = Some(range); } let sh = cx.sheet_mut(sheet)?; + // The array takes the whole range: other cells in it lose their contents. + for c in range.iter().skip(1).take(1_000_000) { + if let Some(old) = sh.cells.get(c).filter(|x| !x.value.is_empty() || x.formula.is_some()) { + let style = old.style; + sh.cells.set(c, Cell { style, ..Cell::default() }); + } + } sh.cells.set(range.start, cell); cx.touch(sheet, range.start); return Ok(Json::Null); @@ -1310,6 +1317,22 @@ fn begin_edit(s: &mut Session, _: &Json) -> Result { mod tests { use super::*; + #[test] + fn legacy_array_entry() { + let mut s = Session::new(); + s.new_workbook(); + s.execute("range.setValues", json!({"range": "B2", "values": [[1], [2]]})).unwrap(); + s.execute("cell.set", json!({"cell": "C4", "input": "old"})).unwrap(); + s.execute("selection.set", json!({"range": "C2:C4"})).unwrap(); + s.execute("cell.set", json!({"cell": "C2", "input": "=B2:B3*2", "array": true})).unwrap(); + let get = |s: &mut Session, a: &str| s.execute("cell.get", json!({"cell": a})).unwrap(); + assert_eq!(get(&mut s, "C3")["value"], json!(4.0)); + // Past the result Excel shows #N/A; the old content of the range is replaced. + assert_eq!(get(&mut s, "C4")["text"], "#N/A"); + let bar = s.doc().unwrap().wb.active().unwrap().cell(CellRef::parse("C2").unwrap()).map(|c| c.bar_text()); + assert_eq!(bar.as_deref(), Some("{=B2:B3*2}")); + } + #[test] fn wildcards() { assert!(wildcard("hello", "h*o")); diff --git a/crates/model/src/cell.rs b/crates/model/src/cell.rs index cd5a6353d..b04216cdb 100644 --- a/crates/model/src/cell.rs +++ b/crates/model/src/cell.rs @@ -75,4 +75,12 @@ impl Cell { v => v.display(), } } + /// What the formula bar shows when not editing: [`input_text`](Self::input_text), with a + /// legacy array formula in braces (`{=A1:A2*2}`). + pub fn bar_text(&self) -> String { + match &self.formula { + Some(f) if f.array.is_some() => format!("{{={}}}", f.text), + _ => self.input_text(), + } + } } diff --git a/crates/ui-egui/src/formula_bar.rs b/crates/ui-egui/src/formula_bar.rs index 753585f10..c5e65a435 100644 --- a/crates/ui-egui/src/formula_bar.rs +++ b/crates/ui-egui/src/formula_bar.rs @@ -70,7 +70,7 @@ fn active_input(app: &SheetApp) -> String { if c.formula.is_some() && d.wb.styles.get(c.style).protection.hidden && sh.is_protected() { return String::new(); } - return c.input_text(); + return c.bar_text(); } // A spilled cell shows the anchor's formula greyed out in Excel; we show it plainly. for (anchor, r) in &sh.spill_ranges { diff --git a/crates/xlsx/src/sheet_read.rs b/crates/xlsx/src/sheet_read.rs index 9f382b15c..845f6eece 100644 --- a/crates/xlsx/src/sheet_read.rs +++ b/crates/xlsx/src/sheet_read.rs @@ -29,8 +29,8 @@ struct Shared { struct RowState { next_row: u32, shared: HashMap, - /// Dynamic-array spill ranges: cached values inside them (other than the anchor) are not - /// stored, so the formula can spill again. + /// Dynamic-array spill ranges and legacy array ranges: cached values inside them (other than + /// the anchor) are not stored, so the formula can fill them again. dynamic: Vec, dropped: u64, rows: u64, @@ -398,11 +398,10 @@ fn read_formula(cx: &mut Ctx<'_>, st: &mut RowState, f: &El, pos: CellRef, dynam } let mut fm = Formula::new(text); let range = f.attr("ref").and_then(RangeRef::parse).unwrap_or(RangeRef::cell(pos)); - if dynamic { - if !range.is_single() { - st.dynamic.push(range); - } - } else { + if !range.is_single() { + st.dynamic.push(range); + } + if !dynamic { fm.array = Some(range); } Some(fm) diff --git a/crates/xlsx/src/sheet_write.rs b/crates/xlsx/src/sheet_write.rs index 94d9b18d1..47fff5492 100644 --- a/crates/xlsx/src/sheet_write.rs +++ b/crates/xlsx/src/sheet_write.rs @@ -100,7 +100,8 @@ pub fn write_sheet(wb: &Workbook, si: usize, selected: bool, out: &mut Out) -> ( .map(|(a, r)| (*a, *r)) .collect(); let mut extra: BTreeMap = BTreeMap::new(); - for (a, r) in &dynamic { + // Values inside spills and legacy array ranges. + for (a, r) in sheet.spill_ranges.iter().filter(|(_, r)| !r.is_single()) { for c in r.iter().take(1_000_000) { if c != *a && let Some(v) = sheet.spill.get(&c) diff --git a/crates/xlsx/src/tests/fixtures.rs b/crates/xlsx/src/tests/fixtures.rs index c84332369..05970a9bd 100644 --- a/crates/xlsx/src/tests/fixtures.rs +++ b/crates/xlsx/src/tests/fixtures.rs @@ -17,8 +17,8 @@ fn shared_formulas_and_value_types() { 25 rich textignored 1#DIV/0!_xlfn.CONCAT("a","b")ab2024-01-01T12:00:00 - A1:B2*22_xlfn.SEQUENCE(3)1 - 2 + A1:B2*226_xlfn.SEQUENCE(3)1 + 42 910 "#; let (wb, rep) = read_xlsx(&minimal(body, &[], "", "")).unwrap(); @@ -35,6 +35,7 @@ fn shared_formulas_and_value_types() { assert_eq!(s.value(at("D4")), Value::Number(45292.5)); let a5 = s.cell(at("A5")).unwrap().formula.clone().unwrap(); assert_eq!(a5.array, Some(RangeRef::parse("A5:B6").unwrap())); + assert!(s.cell(at("B5")).is_none() && s.cell(at("A6")).is_none(), "a legacy array's cached values are dropped too"); let c5 = s.cell(at("C5")).unwrap().formula.clone().unwrap(); assert_eq!(c5.array, None, "dynamic arrays are not legacy CSE arrays"); assert_eq!(c5.text, "SEQUENCE(3)");