Merge pull request #1920 from vedran-dobos:range-fill-formats

This commit is contained in:
Brandon Thomas
2026-10-19 12:19:51 -04:00
3 changed files with 109 additions and 4 deletions
+14 -3
View File
@@ -338,20 +338,31 @@ fn range_fill(s: &mut Session, p: &Json) -> Result<Json> {
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<StyleId, Option<Cell>> = 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);
}
+93
View File
@@ -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");
}
+2 -1
View File
@@ -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 |