From 6ac8ad92166534f608c56816ca06dffd775646f7 Mon Sep 17 00:00:00 2001 From: Vedran Dobos <19259909+vedran-dobos@users.noreply.github.com> Date: Mon, 19 Oct 2026 12:18:34 +0200 Subject: [PATCH] fix(edit): Ctrl+Enter gives each cell the number format typing gives it range.fill parsed the input once at the active cell, then put back the old style of every target that already held a cell, so 5%, $5, 3/4/2026 or =DATE(...) lost their automatic format there (0.05, 5, 46085), and empty targets took the active cell's style. Each target now gets what input_to_cell gives for it (parsed once per distinct style), as typing does. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/edit.rs | 17 ++++- crates/engine/tests/range_fill_formats.rs | 93 +++++++++++++++++++++++ docs/ui-parity.md | 3 +- 3 files changed, 109 insertions(+), 4 deletions(-) create mode 100644 crates/engine/tests/range_fill_formats.rs 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 |