diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index c69bac208..d4df2922e 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -338,20 +338,31 @@ 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 base = input_to_cell(&input, sheet, origin, &mut cx.wb)?; + // Each cell gets what typing the input there gives: the automatic number format where + // it's General, its own format otherwise, text where it's Text. That depends only on + // the cell's style, so the input is parsed once per style. + let mut by_style: std::collections::HashMap> = std::collections::HashMap::new(); for r in &ranges { if r.count() > 2_000_000 { return Err(bad("range.fill", "range too large")); } for at in r.iter() { - let mut cell = base.clone().unwrap_or_default(); + let style = cx.wb.sheet(sheet).map(|sh| sh.style_id(at)).unwrap_or_default(); + let base = match by_style.get(&style) { + Some(c) => c.clone(), + None => { + let c = input_to_cell(&input, sheet, at, &mut cx.wb)?; + by_style.insert(style, c.clone()); + c + } + }; + let mut cell = base.unwrap_or_default(); if let Some(f) = &cell.formula && let Some(e) = f.expr() { let shifted = gridcraft_formula::adjust::shift_relative(e, at.row as i64 - origin.row as i64, at.col as i64 - origin.col as i64); cell.formula = Some(std::sync::Arc::new(Formula::from_expr(shifted))); } - cell.style = cx.wb.sheet(sheet).and_then(|sh| sh.cell(at)).map(|c| c.style).unwrap_or(cell.style); cx.sheet_mut(sheet)?.set_cell(at, cell); cx.touch(sheet, at); } diff --git a/crates/engine/tests/range_fill_formats.rs b/crates/engine/tests/range_fill_formats.rs new file mode 100644 index 000000000..ce1d19fb6 --- /dev/null +++ b/crates/engine/tests/range_fill_formats.rs @@ -0,0 +1,93 @@ +//! Ctrl+Enter (`range.fill`) gives every cell what typing the input there gives it: a General +//! cell gets the input's automatic number format, a formatted cell keeps its own. + +use gridcraft_engine::Session; +use serde_json::{Value, json}; + +fn session() -> Session { + let mut s = Session::new(); + s.new_workbook(); + s +} + +fn get(s: &mut Session, a: &str) -> Value { + s.execute("cell.get", json!({"cell": a})).unwrap() +} + +fn fmt_of(s: &Session, a: &str) -> String { + let wb = &s.doc().unwrap().wb; + let sh = wb.active().unwrap(); + wb.styles.get(sh.style_id(gridcraft_engine::core::CellRef::parse(a).unwrap())).num_fmt.as_str().to_string() +} + +/// Selects `range` with A1 active and fills it with `input`, as Ctrl+Enter does. +fn fill(s: &mut Session, range: &str, input: &str) { + s.execute("selection.set", json!({"range": range, "active": "A1"})).unwrap(); + s.execute("range.fill", json!({"input": input})).unwrap(); +} + +#[test] +fn filled_cells_that_already_hold_a_value_get_the_automatic_format() { + for input in ["5%", "$5", "3/4/2026", "1:30 PM", "1.5E+3"] { + let mut s = session(); + // What typing the input into a General cell gives. + s.execute("cell.set", json!({"cell": "C1", "input": input})).unwrap(); + let typed = get(&mut s, "C1"); + // A1 blank, A2 holding a number: both are General. + s.execute("cell.set", json!({"cell": "A2", "input": "1"})).unwrap(); + fill(&mut s, "A1:A2", input); + for a in ["A1", "A2"] { + let filled = get(&mut s, a); + assert_eq!(filled["text"], typed["text"], "{input} in {a}"); + assert_eq!(filled["value"], typed["value"], "{input} in {a}"); + assert_eq!(fmt_of(&s, a), fmt_of(&s, "C1"), "{input} in {a}"); + } + } +} + +#[test] +fn filled_cells_with_their_own_format_keep_it() { + let mut s = session(); + s.execute("cell.set", json!({"cell": "A2", "input": "1"})).unwrap(); + s.execute("home.formatCells", json!({"range": "A2", "style": {"num_fmt": "0.000"}})).unwrap(); + // Column B is formatted but has no cells yet; row 3 is Text. + s.execute("home.formatCells", json!({"range": "B:B", "style": {"num_fmt": "0.00"}})).unwrap(); + s.execute("home.formatCells", json!({"range": "3:3", "style": {"num_fmt": "@"}})).unwrap(); + fill(&mut s, "A1:B3", "5%"); + assert_eq!(fmt_of(&s, "A1"), "0%"); + assert_eq!(get(&mut s, "A1")["text"], "5%"); + assert_eq!(fmt_of(&s, "A2"), "0.000"); + assert_eq!(get(&mut s, "A2")["text"], "0.050"); + assert_eq!(fmt_of(&s, "B1"), "0.00"); + assert_eq!(get(&mut s, "B1")["text"], "0.05"); + // A Text cell keeps what was typed, as text. + for a in ["A3", "B3"] { + assert_eq!(get(&mut s, a)["type"], "text", "{a}"); + assert_eq!(get(&mut s, a)["text"], "5%", "{a}"); + } +} + +#[test] +fn filled_formulas_still_shift_and_get_their_date_format() { + let mut s = session(); + s.execute("cell.set", json!({"cell": "A2", "input": "1"})).unwrap(); + fill(&mut s, "A1:A2", "=DATE(2026,3,ROW())"); + assert_eq!(get(&mut s, "A2")["formula"], "=DATE(2026,3,ROW())"); + for (a, text) in [("A1", "3/1/2026"), ("A2", "3/2/2026")] { + assert_eq!(fmt_of(&s, a), "m/d/yyyy", "{a}"); + assert_eq!(get(&mut s, a)["text"], text, "{a}"); + } + fill(&mut s, "A1:A2", "=C1*2"); + assert_eq!(get(&mut s, "A2")["formula"], "=C2*2"); +} + +#[test] +fn a_fill_undoes_in_one_step() { + let mut s = session(); + s.execute("cell.set", json!({"cell": "A2", "input": "1"})).unwrap(); + fill(&mut s, "A1:A2", "5%"); + s.execute("edit.undo", json!({})).unwrap(); + assert_eq!(get(&mut s, "A1")["type"], "empty"); + assert_eq!(get(&mut s, "A2")["value"].as_f64(), Some(1.0)); + assert_eq!(fmt_of(&s, "A2"), "General"); +} diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 22e221e69..4256d43cc 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -1,6 +1,6 @@ # UI parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-13 · **Change:** minor (horizontal scrollbar in the sheet tab bar, #840) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-19 · **Change:** minor (Ctrl+Enter fill gives each cell the automatic number format typing gives it) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 Interaction, controls and feel, against Excel. Part of [target-app-parity.md](target-app-parity.md). **UI/UX fidelity: ~55% ready, 50–80 h** (estimated). Ribbon command coverage is measured @@ -161,6 +161,7 @@ scrollbar bugs 2–4 · pixel/feel pass against observed Excel 7–12. | Date | Change | Summary | |---|---|---| +| 2026-10-19 | minor | Ctrl+Enter (`range.fill`) gives each cell what typing the input there gives: a General cell gets the automatic number format (`5%`, `$5`, `3/4/2026`, `=TODAY()`) even when it already held a value, and a cell with its own format, a formatted row or column, or Text keeps it. Cells that held a value used to keep General (`5%` showed 0.05), and empty cells took the active cell's format | | 2026-10-12 | minor | The File view's recent list tells when each workbook was last changed, read off the UI thread | | 2026-10-13 | minor | Horizontal scrollbar in the sheet tab bar: drag, track click pages, frozen columns excluded from the page (#840) | | 2026-10-12 | minor | Ctrl+wheel zoom steps along a ladder of percentages about 20% apart that never skips 100%, gliding to each step with animations on, so a notch out undoes a notch in (it drifted: 100 → 122 → 101); Excel steps 15 points, see decisions.md |