From 8f9338a5ec19188c8a4732682e23858244358077 Mon Sep 17 00:00:00 2001 From: Anirudh Sharma Date: Tue, 13 Oct 2026 14:21:00 +0530 Subject: [PATCH 01/18] fix(fill): respect workbook date system in monthly autofill --- crates/engine/src/fill.rs | 7 ++++--- crates/engine/src/tests.rs | 27 +++++++++++++++++++++++++++ docs/ui-parity.md | 5 +++-- 3 files changed, 34 insertions(+), 5 deletions(-) diff --git a/crates/engine/src/fill.rs b/crates/engine/src/fill.rs index 5848bb0ca..3cd4570aa 100644 --- a/crates/engine/src/fill.rs +++ b/crates/engine/src/fill.rs @@ -165,6 +165,7 @@ enum Pattern { last: f64, first: f64, months: i64, + sys: gridcraft_core::DateSystem, }, } @@ -199,7 +200,7 @@ impl Pattern { && (a.month != b.month || a.year != b.year) { let months = (b.year as i64 - a.year as i64) * 12 + b.month as i64 - a.month as i64; - return Pattern::Months { last, first, months }; + return Pattern::Months { last, first, months, sys }; } } // Least-squares step for 2+ values (Excel uses a linear trend). @@ -297,9 +298,9 @@ impl Pattern { s })) } - Pattern::Months { last, first, months } => { + Pattern::Months { last, first, months, sys } => { let base = if forward { *last } else { *first }; - let s = add_months_clamped(gridcraft_core::DateSystem::D1900, base, months.checked_mul(k * sign)?)?; + let s = add_months_clamped(*sys, base, months.checked_mul(k * sign)?)?; Some(Value::number(s)) } } diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index ae499a189..68ee3632c 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -2897,6 +2897,33 @@ fn fill_series_year_clamps_leap_day() { assert_eq!(v(&s, "A5"), ymd(2032, 2, 29)); } +#[test] +fn autofill_month_pattern_uses_workbook_date_system() { + use std::sync::Arc; + + use gridcraft_core::date::serial_from_ymd; + use gridcraft_core::{DateSystem, RangeRef}; + + for sys in [DateSystem::D1900, DateSystem::D1904] { + for (source, target, seeds, expected) in [ + ("A1:A2", "A1:A6", ["2026-01-31", "2026-03-31"], ["2026-01-31", "2026-03-31", "2026-05-31", "2026-07-31", "2026-09-30", "2026-11-30"]), + ("A5:A6", "A1:A6", ["2026-09-30", "2026-11-30"], ["2026-01-30", "2026-03-30", "2026-05-30", "2026-07-30", "2026-09-30", "2026-11-30"]), + ] { + let mut s = s(); + Arc::make_mut(&mut s.doc_mut().unwrap().wb).date_system = sys; + let start = RangeRef::parse(source).unwrap().start.a1(); + s.execute("range.setValues", json!({"range": start, "values": [[seeds[0]], [seeds[1]]]})).unwrap(); + s.execute("edit.autoFill", json!({"source": source, "target": target})).unwrap(); + for (row, date) in expected.iter().enumerate() { + let parts: Vec = date.split('-').map(|p| p.parse().unwrap()).collect(); + let expected = Value::Number(serial_from_ymd(sys, parts[0], parts[1], parts[2]).unwrap()); + let cell = CellRef::new(row as u32, 0).a1(); + assert_eq!(v(&s, &cell), expected, "{sys:?} {source} -> {target}, {cell}: {date}"); + } + } + } +} + #[test] fn autofill_month_pattern_clamps_to_month_end() { let mut s = s(); diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 22e221e69..a1e74f50f 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-13 · **Change:** minor (AutoFill month patterns respect the workbook's date system) · **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 @@ -45,7 +45,7 @@ this logic, with one undo step. |---|---| | Click/drag/Shift/Ctrl selection, multi-area selection | rectangular drag works in one direction only in some cases (#28) | | Selection outline glides to a new selection (Excel: "Provide feedback with animation") | done (10-11): 0.12 s, decelerating, with the fill and fill handle; View ▸ Animations (`view.animations`) turns it off. The timing is an estimate, not yet measured against Excel; the system's reduced-motion setting isn't followed yet | -| Fill handle: drag to fill series, double-click to fill down, right-drag menu | fill done (Ctrl+drag swaps copy and series); right-drag menu missing | +| Fill handle: drag to fill series, double-click to fill down, right-drag menu | fill done (Ctrl+drag swaps copy and series); month patterns use the workbook's 1900 or 1904 date system; right-drag menu missing | | Drag-move and drag-copy cells (border drag, Ctrl/Option, Shift to insert) | done (M3) | | In-cell editing, Enter/Tab movement, Alt/Option+Enter line breaks | done | | Point mode (click/drag/arrow references), reference colouring, F4 | done | @@ -161,6 +161,7 @@ scrollbar bugs 2–4 · pixel/feel pass against observed Excel 7–12. | Date | Change | Summary | |---|---|---| +| 2026-10-13 | minor | AutoFill month patterns retain the workbook's date system, preventing shifted dates in 1904 workbooks when filling forward or backward | | 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 | From 785913ed47f7c4f33d098a69f1345240b16b54b7 Mon Sep 17 00:00:00 2001 From: nihilist-narwhal <240798405+nihilist-narwhal@users.noreply.github.com> Date: Sat, 10 Oct 2026 05:46:21 -0500 Subject: [PATCH 02/18] Flash Fill: read the text cells show Flash Fill read stored values, so a date was the serial number 45306, not 2024-01-15: with "15/01/2024" given for 2024-01-15 it found no part of the source in the example and filled every row with "15/01/2024" again. It now reads the cells' displayed text, as Excel does. --- crates/engine/src/fill.rs | 20 ++++++++++++++++---- 1 file changed, 16 insertions(+), 4 deletions(-) diff --git a/crates/engine/src/fill.rs b/crates/engine/src/fill.rs index 5848bb0ca..a80513577 100644 --- a/crates/engine/src/fill.rs +++ b/crates/engine/src/fill.rs @@ -442,13 +442,13 @@ pub fn flash_fill(cx: &mut Ctx, sheet: usize, at: CellRef) -> Result { // Header row: skip a first row whose target is text and source looks like a header? Keep simple. let mut examples = Vec::new(); let mut todo = Vec::new(); + // Flash Fill works on the text cells show (a date as formatted, not its serial number). for r in top..=bottom { - let src = sh.value(CellRef::new(r, src_col)).display(); - let dst = sh.value(CellRef::new(r, at.col)); - if dst.is_empty() { + let src = crate::display::cell_text(&cx.wb, sh, CellRef::new(r, src_col)); + if sh.value(CellRef::new(r, at.col)).is_empty() { todo.push((r, src)); } else { - examples.push((src, dst.display())); + examples.push((src, crate::display::cell_text(&cx.wb, sh, CellRef::new(r, at.col)))); } } if examples.is_empty() { @@ -603,6 +603,18 @@ fn synthesize(src: &str, dst: &str) -> Vec { mod tests { use super::*; + #[test] + fn flash_fill_reads_shown_text() { + let mut s = crate::Session::new(); + s.new_workbook(); + let values = serde_json::json!([["2024-01-15", "15/01/2024"], ["2023-12-03", null]]); + s.execute("range.setValues", serde_json::json!({"range": "A1", "values": values})).unwrap(); + s.execute("selection.set", serde_json::json!({"cell": "B2"})).unwrap(); + s.execute("data.flashFill", serde_json::json!({})).unwrap(); + let sh = s.doc().unwrap().wb.active().unwrap(); + assert_eq!(sh.value(CellRef::new(1, 1)), Value::from("03/12/2023")); + } + #[test] fn flash_programs() { let p = learn(&[("John Smith".into(), "Smith, John".into())]).unwrap(); From 2c8ff394cc51f78276eb76f2535cacc0d55e3d14 Mon Sep 17 00:00:00 2001 From: MarekSpodBiedry <73122968+MarekSpodBiedry@users.noreply.github.com> Date: Tue, 13 Oct 2026 18:03:11 +0200 Subject: [PATCH 03/18] Refuse a cut-paste whose destination does not fit instead of dropping cells (#997) --- crates/engine/src/cmd/edit.rs | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index 82dbe0be7..261a11101 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -936,6 +936,10 @@ fn paste_special(s: &mut Session, p: &Json) -> Result { let src_wb = clip.wb.clone(); let Some(src_sheet) = src_wb.sheet(clip.sheet) else { return Err(EngineError::Other("The copied sheet no longer exists.".into())) }; let src_sheet_name = src_sheet.name.clone(); + if cut_move && at.offset((tiles_r * h).saturating_sub(1) as i64, (tiles_c * w).saturating_sub(1) as i64).is_none() { + // Check the whole footprint before the source is emptied, so a move that overflows the grid loses nothing. + return Err(EngineError::Other("The cut area doesn't fit at this position; nothing was moved.".into())); + } edit(s, |cx| { // Styles from another workbook must be re-interned. let mut style_map = std::collections::HashMap::new(); @@ -1675,6 +1679,28 @@ mod tests { assert_eq!(get(&mut s, "E1")["value"], json!(5.0)); } + #[test] + fn cut_paste_that_overflows_the_grid_moves_nothing() { + // #997: the source was emptied first and the cells that fell off the grid were skipped. + let mut s = Session::new(); + s.new_workbook(); + s.execute("range.setValues", json!({"range": "A1", "values": [[1], [2]]})).unwrap(); + let get = |s: &mut Session, a: &str| s.execute("cell.get", json!({"cell": a})).unwrap(); + s.execute("edit.cut", json!({"range": "A1:A2"})).unwrap(); + // Transposed, the 2 rows become 2 columns and the second one is past XFD. + assert!(s.execute("edit.pasteSpecial", json!({"what": "all", "transpose": true, "at": "XFD1"})).is_err()); + assert_eq!(get(&mut s, "A1")["value"], json!(1.0)); + assert_eq!(get(&mut s, "A2")["value"], json!(2.0)); + // Not transposed, the second row would be past the last row. + assert!(s.execute("edit.pasteSpecial", json!({"what": "all", "at": "A1048576"})).is_err()); + assert_eq!(get(&mut s, "A1")["value"], json!(1.0)); + assert_eq!(get(&mut s, "A2")["value"], json!(2.0)); + // Not transposed it still fits in column XFD. + s.execute("edit.pasteSpecial", json!({"what": "all", "at": "XFD1"})).unwrap(); + assert_eq!(get(&mut s, "XFD1")["value"], json!(1.0)); + assert_eq!(get(&mut s, "XFD2")["value"], json!(2.0)); + } + #[test] fn replace_case_insensitive() { assert_eq!(replace_ci("Apple apple APPLE", "apple", "pear"), "pear pear pear"); From 62729e3a99a3c45dd7ef5fb1f9137167606a2e2c Mon Sep 17 00:00:00 2001 From: Marco Arnulfo Date: Wed, 14 Oct 2026 19:52:30 +0200 Subject: [PATCH 04/18] Keyboard: Alt+= no longer starts an edit with the `=` it types (fixes #1327) --- crates/ui-egui/src/grid.rs | 6 +++- crates/ui-egui/tests/keyboard_shortcuts.rs | 33 ++++++++++++++++++++++ 2 files changed, 38 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/grid.rs b/crates/ui-egui/src/grid.rs index 97b787ef3..0190eacbc 100644 --- a/crates/ui-egui/src/grid.rs +++ b/crates/ui-egui/src/grid.rs @@ -2590,6 +2590,9 @@ fn keyboard(app: &mut SheetApp, ctx: &egui::Context, resp: &egui::Response, geo: if !m.alt && (m.ctrl || m.command || (m.shift && *key == egui::Key::Space))) }); let page = ((geo.cells.height() / geo.z) / sh.default_row_height).floor().max(1.0) as i64; + // Alt shortcuts type their character too on Windows and Linux (Alt+= sends Text("=")): once + // a shortcut ran, that text must not start an edit over what it just did. + let mut shortcut_ran = false; for ev in events { match ev { egui::Event::Copy => { @@ -2599,7 +2602,7 @@ fn keyboard(app: &mut SheetApp, ctx: &egui::Context, resp: &egui::Response, geo: app.copy_to_clipboard(ctx, "edit.cut"); } egui::Event::Paste(text) => app.paste_external(text), - egui::Event::Text(_) if shortcut_pressed => {} + egui::Event::Text(_) if shortcut_pressed || shortcut_ran => {} egui::Event::Text(text) if text.chars().all(|c| !c.is_control()) && !text.is_empty() => { app.begin_edit(Some(text), false); if let Some(ed) = app.editor.as_mut() { @@ -2610,6 +2613,7 @@ fn keyboard(app: &mut SheetApp, ctx: &egui::Context, resp: &egui::Response, geo: egui::Event::Key { key, physical_key, pressed: true, modifiers: m, .. } => { use egui::Key; if crate::keymap::dispatch(app, ctx, key, physical_key, m, geo, sh) { + shortcut_ran = true; continue; } let cmd = m.command; diff --git a/crates/ui-egui/tests/keyboard_shortcuts.rs b/crates/ui-egui/tests/keyboard_shortcuts.rs index c0a1307b3..78bc966f5 100644 --- a/crates/ui-egui/tests/keyboard_shortcuts.rs +++ b/crates/ui-egui/tests/keyboard_shortcuts.rs @@ -214,3 +214,36 @@ fn alt_down_only_opens_enabled_table_filter_buttons() { assert_eq!(h.state().session.active().unwrap().selection.active.a1(), "B1"); } } + +/// Alt+= as Windows and Linux report it: the key press, then the `=` the OS types with it. +fn alt_equals(h: &mut Harness) { + h.input_mut().events.push(Event::Key { + key: Key::Equals, + physical_key: Some(Key::Equals), + pressed: true, + repeat: false, + modifiers: Modifiers::ALT, + }); + h.input_mut().events.push(Event::Text("=".into())); + h.run_steps(2); +} + +#[test] +fn alt_equals_autosum_does_not_also_type_the_equals_sign() { + let input = |h: &Harness, a1: &str| h.state().session.active().unwrap().wb.active().unwrap().input_text(CellRef::parse(a1).unwrap()); + // Column above, row to the left, and a block (totals below each column). + for (selected, cell, formula) in [("A4", "A4", "=SUM(A1:A3)"), ("C3", "C3", "=SUM(A3:B3)"), ("A1:B3", "B4", "=SUM(B1:B3)")] { + let mut h = harness(book(selected)); + alt_equals(&mut h); + assert_eq!(input(&h, cell), formula, "Alt+= on {selected}"); + assert!(h.state().editor.is_none(), "Alt+= on {selected} started an edit"); + // Enter moves on: there is no stray `=` edit to commit. + key(&mut h, Key::Enter, Modifiers::NONE); + assert!(h.state().message.is_none(), "Alt+= on {selected}: {:?}", h.state().message); + assert_eq!(input(&h, cell), formula); + } + // Nothing to sum: AutoSum's own edit, `=SUM()`, not `=`. + let mut h = harness(book("E10")); + alt_equals(&mut h); + assert_eq!(h.state().editor.as_ref().map(|e| e.text.clone()).as_deref(), Some("=SUM()")); +} From 938762d796c2d21dbd4c1e2369d50bc24fc5fa32 Mon Sep 17 00:00:00 2001 From: midasmatterhorn <248507632+midasmatterhorn@users.noreply.github.com> Date: Wed, 14 Oct 2026 22:56:07 +0300 Subject: [PATCH 05/18] engine: keep AutoFill text series from panicking or wrapping on i64 overflow --- crates/engine/src/fill.rs | 21 +++++++++++++++++++-- 1 file changed, 19 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/fill.rs b/crates/engine/src/fill.rs index 5848bb0ca..6993b7171 100644 --- a/crates/engine/src/fill.rs +++ b/crates/engine/src/fill.rs @@ -273,12 +273,15 @@ impl Pattern { } Pattern::TextNum { prefix, suffix, last, first, step, width } => { let base = if forward { *last } else { *first }; - let n = base + step * k * sign; + // Text numbers come from input and can sit near i64::MAX (`X 9223372036854775807`): + // an overflowing series yields nothing — the cell keeps its source value — rather + // than wrapping in release or panicking in debug. + let n = step.checked_mul(k)?.checked_mul(sign)?.checked_add(base)?; // `Qtr3`, `Quarter 3` wrap after 4 like `Q3`. let quarter = suffix.is_empty() && (1..=4).contains(&base) && ["q", "qtr", "quarter"].contains(&prefix.trim_end().to_ascii_lowercase().as_str()); - let n = if quarter { (n - 1).rem_euclid(4) + 1 } else { n.abs() }; + let n = if quarter { n.checked_sub(1)?.rem_euclid(4) + 1 } else { n.checked_abs()? }; Some(Value::text(format!("{prefix}{n:0width$}{}", ordinal_suffix(n, suffix), width = *width))) } Pattern::List { list, last, first, step, upper, lower } => { @@ -652,4 +655,18 @@ mod tests { let growth: Vec = (1..6).map(|r| crate::display::cell_text(&d.wb, sh, CellRef::new(r, 0))).collect(); assert_eq!(growth, ["2.5", "1.25", "", "", ""]); } + + #[test] + fn text_series_near_i64_max_neither_wraps_nor_panics() { + let mut s = crate::Session::new(); + s.new_workbook(); + s.execute("cell.set", serde_json::json!({"cell": "A1", "input": "X 0"})).unwrap(); + s.execute("cell.set", serde_json::json!({"cell": "A2", "input": "X 9223372036854775807"})).unwrap(); + // The detected step is i64::MAX, so the next value overflows i64. The cell keeps its + // source value instead of panicking in debug or wrapping in release. + s.execute("edit.autoFill", serde_json::json!({"source": "A1:A2", "target": "A1:A3"})).unwrap(); + let d = s.doc().unwrap(); + let sh = d.wb.active().unwrap(); + assert_eq!(crate::display::cell_text(&d.wb, sh, CellRef::new(2, 0)), "X 0"); + } } From 686c13c8b7f7a7f643a92b62dc1b6ed0aa3f8b4f Mon Sep 17 00:00:00 2001 From: midasmatterhorn <248507632+midasmatterhorn@users.noreply.github.com> Date: Wed, 14 Oct 2026 23:22:05 +0300 Subject: [PATCH 06/18] engine: keep case-insensitive replace from dropping text when folding shifts byte boundaries --- crates/engine/src/cmd/edit.rs | 30 +++++++++++++++++++++++++++++- 1 file changed, 29 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index 82dbe0be7..039893ad8 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -1529,10 +1529,24 @@ fn replace(s: &mut Session, p: &Json) -> Result { Ok(json!({"replaced": n})) } +/// Whether case-folding `s` maps every char to exactly one char of the same UTF-8 length, so +/// byte positions in the folded text still line up with the original's character boundaries. +fn folds_in_place(s: &str) -> bool { + s.chars().all(|c| { + let mut folded = c.to_lowercase(); + let first = folded.next(); + folded.next().is_none() && first.is_some_and(|l| l.len_utf8() == c.len_utf8()) + }) +} + fn replace_ci(text: &str, what: &str, with: &str) -> String { let lower = text.to_lowercase(); let w = what.to_lowercase(); - if lower.len() != text.len() || w.is_empty() { + // Slicing `text` at positions found in `lower` is only sound when folding kept every byte + // boundary: a length check alone misses foldings that keep the total length while shifting + // boundaries (U+212A KELVIN SIGN shrinks 3→1 bytes, U+0130 grows 2→3), where the tail used + // to be dropped. Those fall back to the case-sensitive replace, as length changes already do. + if lower.len() != text.len() || w.is_empty() || !folds_in_place(text) || !folds_in_place(what) { return text.replace(what, with); } let mut out = String::new(); @@ -1679,4 +1693,18 @@ mod tests { fn replace_case_insensitive() { assert_eq!(replace_ci("Apple apple APPLE", "apple", "pear"), "pear pear pear"); } + + #[test] + fn replace_case_insensitive_keeps_text_when_folding_moves_boundaries() { + // U+212A KELVIN SIGN folds to `k` (3→1 bytes) while U+0130 folds to `i` plus a + // combining dot (2→3 bytes): the folded text is the same length but its byte positions + // no longer line up with the original, and the old slicing dropped the tail. + let mut s = Session::new(); + s.new_workbook(); + let text = "\u{212A}\u{0130}\u{0130}"; + s.execute("cell.set", json!({"cell": "A1", "input": text})).unwrap(); + s.execute("edit.replace", json!({"what": "k", "with": "x"})).unwrap(); + let sh = s.doc().unwrap().wb.active().unwrap(); + assert_eq!(sh.value(CellRef::new(0, 0)), Value::text(text)); + } } From 833fe0da1f11f4d2d80f43e45aa4f62a58ee43c2 Mon Sep 17 00:00:00 2001 From: vangel76 Date: Wed, 14 Oct 2026 22:56:36 +0200 Subject: [PATCH 07/18] Edit: text that would parse as a number or formula keeps its quote prefix in the editor (#1362) Co-Authored-By: Claude Sonnet 5.5 --- ROADMAP.md | 1 + crates/engine/src/display.rs | 26 ++++++++++++++++++++++++++ crates/engine/src/tests.rs | 23 +++++++++++++++++++++++ 3 files changed, 50 insertions(+) diff --git a/ROADMAP.md b/ROADMAP.md index 070552d07..aa0d84c2a 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -21,6 +21,7 @@ This page is the summary. The detail is in: | Number | Value | Kind | |---|---|---| +| 2026-10-14 | Editing a cell with text that would read back as a number, boolean, date or formula (entered with a leading apostrophe) shows the apostrophe in the editor and formula bar, so Enter unchanged keeps it text (#1362) | | **Feature breadth** (weighted) | **~75%** | estimated, anchored on two measurements: ribbon catalog 274 / 290 (94.5%), worksheet functions 513 / 523 (98.1%; 504 working, 96.4%) | | **Ready for real work** | **~50%** | estimated from written weights (51.6); [target-app-parity.md](docs/target-app-parity.md) | | **Mainstream practitioner** | **~50%** | estimated: typical analyst/finance user, weekly features, × 0.90 interaction × 0.92 stability × 0.88 file exchange; [method](docs/target-app-parity.md#mainstream-practitioner) | diff --git a/crates/engine/src/display.rs b/crates/engine/src/display.rs index b90e42727..5f54b7b4c 100644 --- a/crates/engine/src/display.rs +++ b/crates/engine/src/display.rs @@ -33,6 +33,25 @@ pub fn format(v: &Value, code: &str, wb: &Workbook) -> Formatted { format_value(v, &parsed(code), wb.date_system) } +/// Whether typing `t` back into a General cell would not give the text `t` again (it would parse +/// as a number, boolean, error, date or formula, or lose a leading apostrophe). Such text is shown +/// for editing with a leading `'`, as Excel does for text carrying a quote prefix, so that +/// committing the editor unchanged keeps the cell as text. +fn text_needs_quote_prefix(t: &str, sys: gridcraft_core::date::DateSystem) -> bool { + if t.is_empty() { + return false; + } + let mut chars = t.chars(); + let (first, second) = (chars.next(), chars.next()); + if matches!(first, Some('=' | '\'')) { + return true; + } + if matches!(first, Some('+' | '-')) && second.is_some_and(|c| c.is_ascii_alphabetic() || c == '(') { + return true; + } + !matches!(gridcraft_core::parse::parse_input(t, sys).value, Value::Text(ref x) if x.as_ref() == t) +} + /// What the cell editor and the formula bar start from. Same as `Cell::input_text`, except that a /// constant number in a date or time format is shown as a date (as the grid shows it), not as its /// serial. When the formatted text would not read back as the same date (for example @@ -41,6 +60,13 @@ pub fn format(v: &Value, code: &str, wb: &Workbook) -> Formatted { pub fn edit_text(wb: &Workbook, sheet: &Sheet, c: CellRef) -> String { let raw = sheet.input_text(c); let Some(cell) = sheet.cell(c) else { return raw }; + if let Value::Text(t) = &cell.value + && cell.formula.is_none() + && wb.styles.get(cell.style).num_fmt.as_str() != "@" + && text_needs_quote_prefix(t, wb.date_system) + { + return format!("'{raw}"); + } let Value::Number(n) = &cell.value else { return raw }; let code = wb.styles.get(cell.style).num_fmt.as_str(); if cell.formula.is_some() || !parsed(code).is_date() { diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index ae499a189..448802477 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -3282,3 +3282,26 @@ fn paste_special_validation_moves_relative_references() { assert!(!ok(&mut s, "C3", "5"), "the pasted rule checks C3, not A1"); assert!(ok(&mut s, "C3", "20")); } + +#[test] +fn editing_quote_prefixed_text_keeps_it_text() { + let mut s = s(); + for (i, input) in ["'00123", "'=1+1", "'TRUE", "''abc"].iter().enumerate() { + let cell = format!("A{}", i + 1); + s.execute("cell.set", json!({"cell": cell, "input": input})).unwrap(); + let at = CellRef::parse(&cell).unwrap(); + let before = v(&s, &cell); + let edit = { + let d = s.doc().unwrap(); + crate::display::edit_text(&d.wb, d.wb.active().unwrap(), at) + }; + assert_eq!(&edit, input, "edit text of {cell}"); + // Committing the editor unchanged leaves the cell as it was. + s.execute("cell.set", json!({"cell": cell, "input": edit})).unwrap(); + assert_eq!(v(&s, &cell), before, "{cell}"); + } + // Plain text is shown as typed, without a prefix. + s.execute("cell.set", json!({"cell": "B1", "input": "hello"})).unwrap(); + let d = s.doc().unwrap(); + assert_eq!(crate::display::edit_text(&d.wb, d.wb.active().unwrap(), CellRef::parse("B1").unwrap()), "hello"); +} From 4567aed5fb3a3ed42d2445eb005328c256a1b0e5 Mon Sep 17 00:00:00 2001 From: Harrison Booth Date: Thu, 15 Oct 2026 02:39:54 -0500 Subject: [PATCH 08/18] fix(engine): AutoFill refuses targets that don't hold the source in a corner edit.autoFill capped source and target at 5M cells separately, but fill() writes one cell per source lane per step, and it never checked the target held the source. Whole rows into whole columns (`2:3` into `A:A`) filled every one of 16,384 columns down a million rows: ~17 billion writes, then a 24-51 GB allocation abort that catch_unwind can't stop. Excel (observed via COM on a synthetic workbook) refuses AutoFill when the target doesn't contain the source or holds it away from every corner, and accepts any corner-sharing target. edit.autoFill now applies that rule (fill::holds_source_in_corner), and fill() bounds lanes x steps by MAX_FILL_CELLS as a backstop. The 5M literals in auto_fill and fill_dir use the shared constant. Regression test: fill::tests::autofill_refuses_targets_that_do_not_extend_the_source. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/edit.rs | 10 ++++-- crates/engine/src/fill.rs | 62 +++++++++++++++++++++++++++++++++++ 2 files changed, 70 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index 82dbe0be7..589afdc6c 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -1306,7 +1306,7 @@ fn fill_dir(s: &mut Session, p: &Json, dr: i64, dc: i64) -> Result { None => return Ok(Json::Null), } } - if r.count() > 5_000_000 { + if r.count() > crate::fill::MAX_FILL_CELLS { return Err(bad("edit.fill", "range too large")); } // With one row selected, Fill Down copies from the row above (Excel behaviour). @@ -1342,9 +1342,15 @@ fn auto_fill(s: &mut Session, p: &Json) -> Result { "values" => crate::fill::FillMode::ValuesOnly, _ => crate::fill::FillMode::Series, }; - if target.count() > 5_000_000 || src.count() > 5_000_000 { + if target.count() > crate::fill::MAX_FILL_CELLS || src.count() > crate::fill::MAX_FILL_CELLS { return Err(bad("edit.autoFill", "range too large")); } + // Excel refuses a target that doesn't hold the source in a corner. Such targets used to fill + // every source lane across the whole target extent: billions of cells for whole rows (`2:3`) + // into whole columns (`A:A`). + if !crate::fill::holds_source_in_corner(src, target) { + return Err(bad("edit.autoFill", "the target must contain the source in one of its corners")); + } edit(s, |cx| { crate::fill::fill(cx, sheet, src, target, mode)?; *cx.sel = crate::Selection { active: src.start, anchor: src.start, ranges: vec![target.union(&src)] }; diff --git a/crates/engine/src/fill.rs b/crates/engine/src/fill.rs index 5848bb0ca..d1db2c946 100644 --- a/crates/engine/src/fill.rs +++ b/crates/engine/src/fill.rs @@ -35,6 +35,18 @@ fn lists() -> Vec> { ] } +/// The most cells one fill may write: the size cap the fill commands put on their ranges. +pub const MAX_FILL_CELLS: u64 = 5_000_000; + +/// Whether `target` is a shape Excel's AutoFill accepts for `src`: it contains the source and the +/// source sits in one of its corners. Excel refuses a target that doesn't contain the source +/// (`2:3` into `A:A`, `H4:H9` into `D:F`) or holds it away from every corner (`H4:H9` into `H:H`). +pub fn holds_source_in_corner(src: RangeRef, target: RangeRef) -> bool { + target.contains_range(&src) + && (target.start.row == src.start.row || target.end.row == src.end.row) + && (target.start.col == src.start.col || target.end.col == src.end.col) +} + /// Fills `target` (which contains `src` at one edge) from the pattern in `src`. pub fn fill(cx: &mut Ctx, sheet: usize, src: RangeRef, target: RangeRef, mode: FillMode) -> Result<()> { let custom = cx.wb.custom_lists.clone(); @@ -46,6 +58,18 @@ pub fn fill(cx: &mut Ctx, sheet: usize, src: RangeRef, target: RangeRef, mode: F } else { target.end.col > src.end.col || target.start.col == src.start.col }; + // Every source lane writes one cell per step past the source: bound that product, not the + // source and target separately (whole rows into whole columns was ~17 billion writes). + let (lane_count, steps) = if vertical { + let steps = if forward { target.end.row.saturating_sub(src.end.row) } else { src.start.row.saturating_sub(target.start.row) }; + (src.width(), steps) + } else { + let steps = if forward { target.end.col.saturating_sub(src.end.col) } else { src.start.col.saturating_sub(target.start.col) }; + (src.height(), steps) + }; + if u64::from(lane_count).saturating_mul(u64::from(steps)) > MAX_FILL_CELLS { + return Err(crate::EngineError::Other("The fill is too large.".into())); + } let lanes: Vec = if vertical { (src.start.col..=src.end.col).collect() } else { (src.start.row..=src.end.row).collect() }; let src_len = if vertical { src.height() } else { src.width() }; let mut writes: Vec<(CellRef, Option)> = Vec::new(); @@ -652,4 +676,42 @@ mod tests { let growth: Vec = (1..6).map(|r| crate::display::cell_text(&d.wb, sh, CellRef::new(r, 0))).collect(); assert_eq!(growth, ["2.5", "1.25", "", "", ""]); } + + #[test] + fn autofill_refuses_targets_that_do_not_extend_the_source() { + // Whole rows into whole columns once filled every source column down the whole sheet + // (~17 billion writes) and aborted the process on a 24-51 GB allocation (fuzz seeds 1064, + // 1114, 1775). Excel refuses every one of these shapes. + let mut s = crate::Session::new(); + s.new_workbook(); + s.execute("range.setValues", serde_json::json!({"range": "A2", "values": [[1, "Item 1"], [2, "Item 2"]]})).unwrap(); + s.execute("range.setValues", serde_json::json!({"range": "H4", "values": [[10], [20]]})).unwrap(); + let before = s.doc().unwrap().wb.active().unwrap().cells.len(); + for (mode, source, target) in [ + ("series", "2:3", "A:A"), + ("alternate", "2:3", "A:A"), + ("series", "8:10", "C:E"), + ("copy", "24:24", "B:D"), + ("values", "H4:H9", "D:F"), + ("series", "H4:H9", "H:H"), + ("series", "A2:A3", "A1:A5"), + ("series", "A2:A6", "A2:A4"), + ("series", "B2:B3", "A2:A3"), + ] { + let r = s.execute("edit.autoFill", serde_json::json!({"mode": mode, "source": source, "target": target})); + assert!(r.is_err(), "{source} -> {target} should be refused"); + } + assert_eq!(s.doc().unwrap().wb.active().unwrap().cells.len(), before, "a refused fill writes nothing"); + // Targets holding the source in a corner still fill, whole rows and diagonal drags included. + s.execute("edit.autoFill", serde_json::json!({"source": "2:3", "target": "2:6"})).unwrap(); + s.execute("edit.autoFill", serde_json::json!({"source": "A2:A3", "target": "A1:A3"})).unwrap(); + let sh = s.doc().unwrap().wb.active().unwrap(); + let text = |a: &str| crate::display::cell_text(&s.doc().unwrap().wb, sh, CellRef::parse(a).unwrap()); + assert_eq!([text("A1"), text("A6"), text("B6")], ["0", "5", "Item 5"]); + assert!(holds_source_in_corner(RangeRef::parse("B5").unwrap(), RangeRef::parse("B3:B5").unwrap())); + assert!(holds_source_in_corner(RangeRef::parse("A:A").unwrap(), RangeRef::parse("A:C").unwrap())); + assert!(holds_source_in_corner(RangeRef::parse("B5:C9").unwrap(), RangeRef::parse("B5:D13").unwrap())); + assert!(holds_source_in_corner(RangeRef::parse("B1:B2").unwrap(), RangeRef::parse("A1:B4").unwrap())); + assert!(!holds_source_in_corner(RangeRef::parse("A:A").unwrap(), RangeRef::parse("1:1").unwrap())); + } } From e6c0f92d59226953eec7aed841bd186f9a178427 Mon Sep 17 00:00:00 2001 From: midasmatterhorn <248507632+midasmatterhorn@users.noreply.github.com> Date: Thu, 15 Oct 2026 17:30:35 +0300 Subject: [PATCH 09/18] engine: a cut takes the source's notes, hyperlinks and merged areas with it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A cut removed the source's cells and pictures and then re-created the block's merged areas at the destination and copied its notes there — but removed nothing from the source, so one merged block became two (an empty one left behind) and a note ended up on both the old and the new cell. Excel moves both and leaves the source clean. The cut now clears the source range's comments, hyperlinks and merges the same way edit.clear does. A second gap in the same path: paste never transferred hyperlinks at all, so clearing the source would have lost them — paste now carries hyperlinks (in What::All and a new What::Hyperlinks, so edit.pasteSpecial {"what":"hyperlinks"} works like "comments"). Regression test: a_cut_moves_the_sources_notes_hyperlinks_and_merges — on main the old merge stays behind and the note is on both cells; with the fix the merge, note and link all move and the source is clean. --- crates/engine/src/cmd/edit.rs | 21 ++++++++++++++++++++- crates/engine/src/tests.rs | 30 ++++++++++++++++++++++++++++++ 2 files changed, 50 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index 82dbe0be7..0ae044b5c 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -769,6 +769,7 @@ enum What { Values, Formats, Comments, + Hyperlinks, Validation, AllExceptBorders, ColumnWidths, @@ -903,6 +904,7 @@ fn paste_special(s: &mut Session, p: &Json) -> Result { "values" => What::Values, "formats" => What::Formats, "comments" | "notes" => What::Comments, + "hyperlinks" | "links" => What::Hyperlinks, "validation" => What::Validation, "allExceptBorders" => What::AllExceptBorders, "columnWidths" => What::ColumnWidths, @@ -952,6 +954,13 @@ fn paste_special(s: &mut Session, p: &Json) -> Result { let source = cx.sheet_mut(clip.sheet)?; moved_pictures = source.cell_pictures.iter().filter(|(c, _)| src.contains(**c)).map(|(c, p)| (*c, p.clone())).collect(); let taken = source.take_cells(src); + // Notes, hyperlinks and merged areas sit beside the cells, and a cut takes them + // with the block: the destination is written from the clipboard snapshot taken + // before the cut, so removing them here loses nothing and leaves the old place + // clean instead of leaving one merged block and one note behind. + source.comments.retain(|c, _| !src.contains(*c)); + source.hyperlinks.retain(|c, _| !src.contains(*c)); + source.merges.retain(|m| !src.contains_range(m)); for (c, _) in &taken { cx.touch(clip.sheet, *c); } @@ -1048,7 +1057,7 @@ fn paste_special(s: &mut Session, p: &Json) -> Result { } } What::Formats => new.style = map_style(sc_cell.style, &mut cx.wb), - What::Comments | What::Validation | What::ColumnWidths => {} + What::Comments | What::Hyperlinks | What::Validation | What::ColumnWidths => {} } // Operations combine numbers with what is already there. if op != "none" @@ -1121,6 +1130,13 @@ fn paste_special(s: &mut Session, p: &Json) -> Result { cx.sheet_mut(dest_sheet)?.comments.insert(dest, cm); } } + // A cut moves the block's hyperlinks with it, so they must reach the + // destination too (only `edit.clear` touched them before). + What::Hyperlinks => { + if let Some(h) = src_sheet.hyperlinks.get(&sc).cloned() { + cx.sheet_mut(dest_sheet)?.hyperlinks.insert(dest, h); + } + } What::ColumnWidths => { if let Some(info) = src_sheet.cols.get(&sc.col).copied() { cx.sheet_mut(dest_sheet)?.cols.insert(dest.col, info); @@ -1130,6 +1146,9 @@ fn paste_special(s: &mut Session, p: &Json) -> Result { if let Some(cm) = src_sheet.comments.get(&sc).cloned() { cx.sheet_mut(dest_sheet)?.comments.insert(dest, cm); } + if let Some(h) = src_sheet.hyperlinks.get(&sc).cloned() { + cx.sheet_mut(dest_sheet)?.hyperlinks.insert(dest, h); + } cx.sheet_mut(dest_sheet)?.set_cell(dest, new); } _ => cx.sheet_mut(dest_sheet)?.set_cell(dest, new), diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index ae499a189..5c18b4ecf 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -2925,6 +2925,36 @@ fn notes_comments_and_links_survive_a_json_save() { assert_eq!(after.cells, before.cells); } +/// A cut moves the block's notes, hyperlinks and merged areas with it. The source's cells and +/// pictures were removed but the block's merges and notes were not, so one merged block became +/// two (an empty one left at the old place) and a note ended up on both the old and the new +/// cell. Hyperlinks were not transferred at all. +#[test] +fn a_cut_moves_the_sources_notes_hyperlinks_and_merges() { + let mut s = s(); + s.execute("cell.set", json!({"cell": "A1", "input": "x"})).unwrap(); + s.execute("cell.set", json!({"cell": "B1", "input": "y"})).unwrap(); + s.execute("review.newNote", json!({"cell": "A1", "text": "note", "author": "Example"})).unwrap(); + s.execute("insert.link", json!({"cell": "A1", "target": "https://example.com"})).unwrap(); + s.execute("home.mergeCells", json!({"range": "A1:B1"})).unwrap(); + + s.execute("edit.cut", json!({"range": "A1:B1"})).unwrap(); + s.execute("edit.paste", json!({"at": "D1"})).unwrap(); + + let d = s.doc().unwrap(); + let sh = d.wb.active().unwrap(); + let (a1, d1) = (CellRef::parse("A1").unwrap(), CellRef::parse("D1").unwrap()); + assert_eq!(sh.merges, vec![gridcraft_core::RangeRef::parse("D1:E1").unwrap()], "the old merge must not stay behind"); + assert!(!sh.comments.contains_key(&a1), "the note moved"); + assert!(sh.comments.contains_key(&d1), "the note is at the new cell"); + assert!(!sh.hyperlinks.contains_key(&a1), "the hyperlink moved"); + assert!(sh.hyperlinks.contains_key(&d1), "the hyperlink is at the new cell"); + // The cells themselves moved as before; E1 is covered by the pasted merge (merging + // A1:B1 cleared B1, so only the anchor's content travels). + assert_eq!(v(&s, "D1"), Value::text("x")); + assert!(v(&s, "A1").is_empty() && v(&s, "B1").is_empty()); +} + /// Insert › Link sorts a target as Excel's dialog does: a sheet reference, a defined name or `#…` is /// a place in this document, anything else an existing file or web page. A file saved as a /// `location` opens nothing in Excel (#800). From 883a07fb7bf6ac5c8354d2c25a3d97b7d5374823 Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Thu, 15 Oct 2026 17:31:12 +0200 Subject: [PATCH 10/18] Keep nan/inf time and fraction look-alikes as text Found while testing crates/core/src/parse.rs:~170 (parse_time, parts parsed with f64::parse) Co-Authored-By: Claude Haiku 5.5 Co-Authored-By: Claude Sonnet 5.5 --- crates/core/src/parse.rs | 33 +++++++++++++++++++++++++++------ 1 file changed, 27 insertions(+), 6 deletions(-) diff --git a/crates/core/src/parse.rs b/crates/core/src/parse.rs index b465beaf9..f525b7966 100644 --- a/crates/core/src/parse.rs +++ b/crates/core/src/parse.rs @@ -146,14 +146,28 @@ fn parse_plain(t: &str) -> Option<(f64, Option<&'static str>)> { Some((n, fmt)) } +/// A plain ASCII number: digits, with an optional `.digits` fraction when `frac` is set. Rejects +/// `nan`, `inf`, signs and exponents that `f64::from_str` would accept. +fn plain_number(s: &str, frac: bool) -> Option { + let (int, dec) = match s.split_once('.') { + Some((i, d)) if frac => (i, Some(d)), + _ => (s, None), + }; + let digits = |x: &str| !x.is_empty() && x.bytes().all(|c| c.is_ascii_digit()); + if !digits(int) || dec.is_some_and(|d| !digits(d)) { + return None; + } + s.parse().ok() +} + /// `1/2` (only with a whole part, else it's a date), `1 1/2`, `0 3/4`. fn parse_fraction(t: &str) -> Option { let (whole, frac) = t.split_once(' ')?; let neg = whole.starts_with('-'); - let w: f64 = whole.trim_start_matches('-').parse().ok()?; + let w = plain_number(whole.trim_start_matches('-'), false)?; let (a, b) = frac.trim().split_once('/')?; - let a: f64 = a.parse().ok()?; - let b: f64 = b.parse().ok()?; + let a = plain_number(a, false)?; + let b = plain_number(b, false)?; if b == 0.0 || !w.is_finite() { return None; } @@ -187,9 +201,9 @@ fn parse_time(t: &str) -> Option<(f64, bool, bool)> { if parts.len() > 3 || parts.iter().any(|p| p.is_empty()) { return None; } - let mut h: f64 = parts.first()?.parse().ok()?; - let m: f64 = parts.get(1).map_or(Some(0.0), |p| p.parse().ok())?; - let s: f64 = parts.get(2).map_or(Some(0.0), |p| p.parse().ok())?; + let mut h = plain_number(parts.first()?, false)?; + let m = parts.get(1).map_or(Some(0.0), |p| plain_number(p, false))?; + let s = parts.get(2).map_or(Some(0.0), |p| plain_number(p, true))?; if m >= 60.0 || s >= 60.0 || h < 0.0 || m < 0.0 || s < 0.0 { return None; } @@ -326,6 +340,13 @@ mod tests { parse_input(s, DateSystem::D1900) } + #[test] + fn non_digit_time_and_fraction_parts_stay_text() { + for s in ["1:nan", "nan:30", "1:30:NaN", "1 nan/2", "1 1/nan", "inf:00", "1e1:00", "+5:00"] { + assert_eq!(p(s).value, Value::text(s), "{s}"); + } + } + #[test] fn month_name_short_year() { for s in ["Mar 4, 26", "Mar 4 26", "March 4 26", "4 March 26", "Mar 4, 2026"] { From 0033dfcdeb49d34a240facd89f5c79692aa5e5e4 Mon Sep 17 00:00:00 2001 From: Marco Arnulfo Date: Fri, 16 Oct 2026 06:19:24 +0200 Subject: [PATCH 11/18] Keyboard: Ctrl+Shift+A after a function name inserts its argument names --- crates/ui-egui/src/editor.rs | 38 +++++++++++ crates/ui-egui/src/formula_bar.rs | 16 +++++ crates/ui-egui/tests/argument_names.rs | 90 ++++++++++++++++++++++++++ docs/ui-parity.md | 6 +- 4 files changed, 148 insertions(+), 2 deletions(-) create mode 100644 crates/ui-egui/tests/argument_names.rs diff --git a/crates/ui-egui/src/editor.rs b/crates/ui-egui/src/editor.rs index 4aada2953..4837f8bd7 100644 --- a/crates/ui-egui/src/editor.rs +++ b/crates/ui-egui/src/editor.rs @@ -144,6 +144,44 @@ impl EditState { true } + /// Ctrl+Shift+A, as in Excel: right after a function name, or after its `(`, inserts the + /// argument names (optional ones without their brackets) and the closing parenthesis, and + /// the `(` too when missing. Anywhere else nothing changes and it returns false. + pub fn insert_argument_names(&mut self) -> bool { + if !self.is_formula() { + return false; + } + let caret_b = self.byte_caret(); + let (Some(before), Some(after)) = (self.text.get(..caret_b), self.text.get(caret_b..)) else { return false }; + // An odd number of quotes before the caret: it is inside a text literal. + if before.matches('"').count() % 2 == 1 { + return false; + } + let open = before.ends_with('('); + if !open && after.starts_with('(') { + return false; + } + let head = before.strip_suffix('(').unwrap_or(before); + let name: String = + head.chars().rev().take_while(|c| c.is_ascii_alphanumeric() || *c == '.' || *c == '_').collect::().chars().rev().collect(); + if name.is_empty() || name.starts_with(|c: char| c.is_ascii_digit()) { + return false; + } + let Some(signature) = crate::formula_locale::signature(&name, self.locale) else { return false }; + let sep = self.locale.list_separator(); + let inner = signature.split_once('(').map(|(_, rest)| rest.trim_end().trim_end_matches(')')).unwrap_or(""); + let args: Vec<&str> = inner.split(sep).map(|a| a.trim().trim_start_matches('[').trim_end_matches(']')).filter(|a| !a.is_empty()).collect(); + let close = if open && after.starts_with(')') { "" } else { ")" }; + let inserted = format!("{}{}{close}", if open { "" } else { "(" }, args.join(&sep.to_string())); + self.text = format!("{before}{inserted}{after}"); + self.caret += inserted.chars().count(); + self.autocomplete.clear(); + self.completion = None; + self.caret_to_end = false; + self.sync_caret = true; + true + } + /// The function call the caret is inside and the argument index (for the hint tooltip). pub fn current_function(&self) -> Option<(String, usize)> { if !self.is_formula() { diff --git a/crates/ui-egui/src/formula_bar.rs b/crates/ui-egui/src/formula_bar.rs index 7f1ad87fc..da58c6504 100644 --- a/crates/ui-egui/src/formula_bar.rs +++ b/crates/ui-egui/src/formula_bar.rs @@ -255,6 +255,22 @@ pub fn editor_widget(app: &mut SheetApp, ui: &mut egui::Ui, id: egui::Id, font: if ui.input_mut(|i| i.consume_key(egui::Modifiers::NONE, Key::F4)) { cycle_anchor(&mut ed); } + // Ctrl+Shift+A (Control+Shift+A on a Mac) inserts the argument names. Consumed anywhere, + // as in Excel, so it never falls through to the text's select-all. + if ui.input_mut(|i| i.consume_key(egui::Modifiers::CTRL | egui::Modifiers::SHIFT, Key::A)) { + let (text, caret) = (ed.text.clone(), ed.caret); + if ed.insert_argument_names() + && let Some(mut state) = egui::TextEdit::load_state(ui.ctx(), id) + { + // The text edit records an undo point only once typing pauses: mark the text + // before the shortcut so one Undo takes back just the inserted names. + let mut undoer = state.undoer(); + let cursor = state.cursor.char_range().unwrap_or(egui::text::CCursorRange::one(egui::text::CCursor::new(caret))); + undoer.add_undo(&(cursor, text)); + state.set_undoer(undoer); + state.store(ui.ctx(), id); + } + } } // The text widget. let refs = formula_refs(&ed.text); diff --git a/crates/ui-egui/tests/argument_names.rs b/crates/ui-egui/tests/argument_names.rs new file mode 100644 index 000000000..1702cf839 --- /dev/null +++ b/crates/ui-egui/tests/argument_names.rs @@ -0,0 +1,90 @@ +//! Ctrl+Shift+A (Control+Shift+A on a Mac) after a function name inserts its argument names, as +//! in Excel. +use egui::{Context, Event, Key, Modifiers, RawInput, Rect, pos2, vec2}; +use gridcraft_engine::Session; +use gridcraft_ui_egui::SheetApp; + +fn frame(ctx: &Context, app: &mut SheetApp, time: f64, events: Vec) { + let mut output = ctx.run_ui( + RawInput { screen_rect: Some(Rect::from_min_size(pos2(0.0, 0.0), vec2(1200.0, 800.0))), time: Some(time), events, ..Default::default() }, + |ui| { + let ctx = ui.ctx().clone(); + app.logic(&ctx); + app.ui(ui); + }, + ); + output.textures_delta.clear(); +} + +fn key(key: Key, modifiers: Modifiers) -> Event { + Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers } +} + +fn ctrl_shift_a() -> Event { + key(Key::A, Modifiers::CTRL | Modifiers::SHIFT) +} + +/// A cell editor holding `text`, caret at the end. +fn editing(text: &str) -> (Context, SheetApp) { + let ctx = Context::default(); + let mut session = Session::new(); + session.new_workbook(); + let mut app = SheetApp::new(session, Default::default()); + for t in 0..4 { + frame(&ctx, &mut app, f64::from(t) * 0.01, vec![]); + } + app.begin_edit(Some(text.into()), false); + for t in 4..8 { + frame(&ctx, &mut app, f64::from(t) * 0.01, vec![]); + } + (ctx, app) +} + +fn text(app: &SheetApp) -> &str { + app.editor.as_ref().map(|e| e.text.as_str()).unwrap_or("") +} + +#[test] +fn after_the_opening_parenthesis_inserts_the_argument_names() { + let (ctx, mut app) = editing("=SUMIF("); + frame(&ctx, &mut app, 0.1, vec![ctrl_shift_a()]); + frame(&ctx, &mut app, 0.11, vec![]); + assert_eq!(text(&app), "=SUMIF(range,criteria,sum_range)"); +} + +#[test] +fn after_a_bare_function_name_also_adds_the_parentheses() { + for (typed, expected) in + [("=SUMIF", "=SUMIF(range,criteria,sum_range)"), ("=1+sumif", "=1+sumif(range,criteria,sum_range)"), ("=TODAY", "=TODAY()")] + { + let (ctx, mut app) = editing(typed); + frame(&ctx, &mut app, 0.1, vec![ctrl_shift_a()]); + frame(&ctx, &mut app, 0.11, vec![]); + assert_eq!(text(&app), expected, "after {typed}"); + } +} + +#[test] +fn anywhere_else_the_shortcut_changes_nothing() { + for typed in ["=SUMIF(A1", "=SUMIF(A1,", "=NOTAFUNCTION", "=A1+", "=\"SUMIF(", "SUMIF("] { + let (ctx, mut app) = editing(typed); + frame(&ctx, &mut app, 0.1, vec![ctrl_shift_a()]); + frame(&ctx, &mut app, 0.11, vec![]); + assert_eq!(text(&app), typed, "after {typed}"); + } +} + +#[test] +fn one_undo_takes_back_the_inserted_names_only() { + let (ctx, mut app) = editing("=SUMI"); + // Typed just before the shortcut: the text edit's own undo has not settled on it yet. + frame(&ctx, &mut app, 0.1, vec![Event::Text("F(".into())]); + frame(&ctx, &mut app, 0.11, vec![]); + assert_eq!(text(&app), "=SUMIF("); + frame(&ctx, &mut app, 0.12, vec![ctrl_shift_a()]); + frame(&ctx, &mut app, 0.13, vec![]); + assert_eq!(text(&app), "=SUMIF(range,criteria,sum_range)"); + frame(&ctx, &mut app, 0.14, vec![key(Key::Z, Modifiers::COMMAND)]); + frame(&ctx, &mut app, 0.15, vec![]); + assert_eq!(text(&app), "=SUMIF("); +} diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 22e221e69..6a0194322 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-16 · **Change:** minor (Ctrl+Shift+A inserts a function's argument names) · **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 @@ -124,7 +124,8 @@ otherwise. Number-format and border keys match the physical key (Ctrl+Shift+4, w | F5, Shift+F5 (Mac), F7, Shift+F9, Ctrl+Alt+F9, F12, Ctrl+F4 | same | Go To, Find, Spelling, calculate sheet / all, Save As, close | **added** | | Shift+F10, Alt+Shift+F10, Ctrl+F1 | Shift+F10, Cmd+Option+R | context menu, Error Checking, ribbon | **added** | | F1, F3, F4 (repeat), F6 / Shift+F6, F8 / Shift+F8, F11, End mode | same | help, Paste Name, Repeat, pane cycling, extend/add mode, chart sheet | missing (no such feature yet) | -| Ctrl+6, Ctrl+8, Ctrl+Q, Ctrl+Shift+A, Ctrl+A after a function name, Ctrl+Shift+U, Ctrl+Alt+P | same | objects, outline symbols, Quick Analysis, argument names, Function Arguments, formula bar height, tooltips | missing | +| Ctrl+Shift+A after a function name | Control+Shift+A | argument names: `=SUMIF(` becomes `=SUMIF(range,criteria,sum_range)`, the `(` added when missing; one Undo takes them back | **added** | +| Ctrl+6, Ctrl+8, Ctrl+Q, Ctrl+A after a function name, Ctrl+Shift+U, Ctrl+Alt+P | same | objects, outline symbols, Quick Analysis, Function Arguments, formula bar height, tooltips | missing | | Ctrl+Shift+Page Up / Down, Ctrl+Alt+5, Ctrl+Shift+F1, Ctrl+F5–F10, Alt+F8 / F11 / F12 | same | group sheets, shape cycling, full screen, window keys, macros, VBA, Power Query | missing | | — | Cmd+= (calculate), Cmd+T (references while editing; F4 works), Shift+F7, Cmd+Shift+L | Mac-only extras | missing (Cmd+= is egui's zoom key; no thesaurus or Modify Cell Style) | @@ -161,6 +162,7 @@ scrollbar bugs 2–4 · pixel/feel pass against observed Excel 7–12. | Date | Change | Summary | |---|---|---| +| 2026-10-16 | minor | Ctrl+Shift+A (Control+Shift+A on a Mac) after a function name, or after its `(`, inserts the argument names and the closing parenthesis, as in Excel; one Undo in the editor takes them back | | 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 | From 963219200c558f4b2b41f38c663e50f0d8ef3fb1 Mon Sep 17 00:00:00 2001 From: MarekSpodBiedry <73122968+MarekSpodBiedry@users.noreply.github.com> Date: Sat, 17 Oct 2026 07:45:31 +0200 Subject: [PATCH 12/18] Ctrl+Alt+letter that types a character no longer triggers the Ctrl shortcut (#1530) --- crates/ui-egui/src/grid.rs | 4 +++- crates/ui-egui/src/keymap.rs | 10 +++++++--- crates/ui-egui/tests/keyboard_shortcuts.rs | 11 +++++++++++ docs/ui-parity.md | 6 ++++-- 4 files changed, 25 insertions(+), 6 deletions(-) diff --git a/crates/ui-egui/src/grid.rs b/crates/ui-egui/src/grid.rs index 00e0c18be..b04a3e7a3 100644 --- a/crates/ui-egui/src/grid.rs +++ b/crates/ui-egui/src/grid.rs @@ -2681,7 +2681,9 @@ fn keyboard(app: &mut SheetApp, ctx: &egui::Context, resp: &egui::Response, geo: } Key::F9 => app.run_or_alert("formulas.calculateNow", json!({})), Key::F4 if !cmd => {} - _ if cmd => crate::ribbon::shortcut(app, key, m), + _ if cmd && !crate::keymap::altgr_text(ctx, m, ctx.os() == egui::os::OperatingSystem::Mac) => { + crate::ribbon::shortcut(app, key, m) + } _ => {} } } diff --git a/crates/ui-egui/src/keymap.rs b/crates/ui-egui/src/keymap.rs index 032db1a2d..92f7093d7 100644 --- a/crates/ui-egui/src/keymap.rs +++ b/crates/ui-egui/src/keymap.rs @@ -162,12 +162,16 @@ pub fn lookup(key: Key, physical: Option, m: Modifiers, mac: bool) -> Optio }) } +/// AltGr arrives as Ctrl+Alt on Windows: a key that also typed a character (AltGr+= is `~` on a German +/// layout; a user-defined Ctrl+Alt+A hotkey may type `ä`) is text, not a shortcut. +pub(crate) fn altgr_text(ctx: &egui::Context, m: Modifiers, mac: bool) -> bool { + !mac && m.alt && m.ctrl && ctx.input(|i| i.events.iter().any(|e| matches!(e, egui::Event::Text(_)))) +} + /// Handles a key press in the grid. Returns whether it was a shortcut. pub(crate) fn dispatch(app: &mut SheetApp, ctx: &egui::Context, key: Key, physical: Option, m: Modifiers, geo: &Geo, sh: &Sheet) -> bool { let mac = ctx.os() == egui::os::OperatingSystem::Mac; - // AltGr arrives as Ctrl+Alt on Windows: a key that also typed a character (AltGr+= is `~` - // on a German layout) is text, not a shortcut. - if !mac && m.alt && m.ctrl && ctx.input(|i| i.events.iter().any(|e| matches!(e, egui::Event::Text(_)))) { + if altgr_text(ctx, m, mac) { return false; } let Some(shortcut) = lookup(key, physical, m, mac) else { return false }; diff --git a/crates/ui-egui/tests/keyboard_shortcuts.rs b/crates/ui-egui/tests/keyboard_shortcuts.rs index c0a1307b3..7693d149f 100644 --- a/crates/ui-egui/tests/keyboard_shortcuts.rs +++ b/crates/ui-egui/tests/keyboard_shortcuts.rs @@ -214,3 +214,14 @@ fn alt_down_only_opens_enabled_table_filter_buttons() { assert_eq!(h.state().session.active().unwrap().selection.active.a1(), "B1"); } } + +#[test] +fn ctrl_alt_letter_that_typed_text_is_not_select_all() { + let mut h = harness(book("B2")); + let ctrl_alt = Modifiers { alt: true, ..CTRL }; + h.input_mut().events.push(Event::Key { key: Key::A, physical_key: Some(Key::A), pressed: true, repeat: false, modifiers: ctrl_alt }); + h.input_mut().events.push(Event::Text("\u{e4}".into())); + h.run_steps(2); + assert_eq!(h.state().session.active().unwrap().selection.current().a1(), "B2"); + assert_eq!(h.state().editor.as_ref().unwrap().text, "\u{e4}"); +} diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 22e221e69..24d2774d6 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-17 · **Change:** minor (Ctrl+Alt+letter that types a character skips Ctrl shortcuts, #1530) · **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 @@ -129,7 +129,8 @@ otherwise. Number-format and border keys match the physical key (Ctrl+Shift+4, w | — | Cmd+= (calculate), Cmd+T (references while editing; F4 works), Shift+F7, Cmd+Shift+L | Mac-only extras | missing (Cmd+= is egui's zoom key; no thesaurus or Modify Cell Style) | On Windows, AltGr arrives as Ctrl+Alt: a Ctrl+Alt key that also types a character is treated as -text, so AltGr symbols (e.g. `~` on a German layout) never trigger a shortcut. +text, so AltGr symbols (e.g. `~` on a German layout) and user-defined Ctrl+Alt+letter hotkeys that type +a character (e.g. Ctrl+Alt+A for `ä`) never trigger a shortcut, Ctrl+letter ones like Select All included. ## Dialogs @@ -161,6 +162,7 @@ scrollbar bugs 2–4 · pixel/feel pass against observed Excel 7–12. | Date | Change | Summary | |---|---|---| +| 2026-10-17 | minor | A Ctrl+Alt+letter key that types a character (a user-defined Windows hotkey such as Ctrl+Alt+A for `ä`) types it instead of running the Ctrl+letter shortcut, e.g. Select All (#1530) | | 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 | From d312535c4068c5a0ec44b3d42dfca3757b65c320 Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Mon, 19 Oct 2026 13:58:46 +0200 Subject: [PATCH 13/18] Keep date text with signed parts like +3/4/2026 as text Found while testing crates/core/src/parse.rs:214 Co-Authored-By: Claude Haiku 5.5 Co-Authored-By: Claude Sonnet 5.5 --- crates/core/src/parse.rs | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/crates/core/src/parse.rs b/crates/core/src/parse.rs index b465beaf9..f39e914ae 100644 --- a/crates/core/src/parse.rs +++ b/crates/core/src/parse.rs @@ -184,7 +184,7 @@ fn parse_time(t: &str) -> Option<(f64, bool, bool)> { if parts.len() == 1 && ampm.is_none() { return None; } - if parts.len() > 3 || parts.iter().any(|p| p.is_empty()) { + if parts.len() > 3 || parts.iter().any(|p| p.is_empty() || p.starts_with('+')) { return None; } let mut h: f64 = parts.first()?.parse().ok()?; @@ -211,7 +211,13 @@ fn parse_time(t: &str) -> Option<(f64, bool, bool)> { fn parse_date(t: &str, sys: DateSystem) -> Option<(f64, &'static str)> { let t = t.trim().trim_end_matches(','); - let num = |s: &str| s.trim().parse::().ok(); + let num = |s: &str| { + let s = s.trim(); + if s.is_empty() || !s.bytes().all(|b| b.is_ascii_digit()) { + return None; + } + s.parse::().ok() + }; // ISO yyyy-mm-dd or yyyy/mm/dd let seps: &[char] = &['/', '-', '.']; let parts: Vec<&str> = t.split(seps).collect(); @@ -326,6 +332,14 @@ mod tests { parse_input(s, DateSystem::D1900) } + #[test] + fn signed_date_parts_stay_text() { + for s in ["+3/4/2026", "3/+4/2026", "3/4/+2026", "1/1/2020 +5:00"] { + assert_eq!(p(s).value, Value::text(s), "{s}"); + } + assert_eq!(p("3/4/2026").value, Value::Number(46085.0)); + } + #[test] fn month_name_short_year() { for s in ["Mar 4, 26", "Mar 4 26", "March 4 26", "4 March 26", "Mar 4, 2026"] { From 3a2232cfef5351ac93d645f9589d7fd80be2fe46 Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Mon, 19 Oct 2026 18:31:57 +0200 Subject: [PATCH 14/18] Enter bare numbers in Percentage-formatted cells as percents Fixes #1967 Co-Authored-By: Claude Haiku 5.5 Co-Authored-By: Claude Sonnet 5.5 --- crates/engine/src/cmd/edit.rs | 12 +++++++++++- crates/engine/tests/percent_entry.rs | 20 ++++++++++++++++++++ 2 files changed, 31 insertions(+), 1 deletion(-) create mode 100644 crates/engine/tests/percent_entry.rs diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index c69bac208..4ab591044 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -176,7 +176,17 @@ pub(crate) fn input_to_cell(input: &str, sheet: usize, at: CellRef, wb: &mut gri } return Ok(Some(cell)); } - let parsed = gridcraft_core::parse::parse_input(input, wb.date_system); + let mut parsed = gridcraft_core::parse::parse_input(input, wb.date_system); + // Like Excel's automatic percent entry: a bare number of 1 or more typed into a cell already + // formatted as a percentage is a percent (3 -> 3%); smaller numbers are taken as fractions. + if parsed.format.is_none() + && !input.contains('%') + && wb.styles.get(style).num_fmt.as_str().ends_with('%') + && let Value::Number(n) = parsed.value + && n.abs() >= 1.0 + { + parsed.value = Value::Number(n / 100.0); + } let mut style = style; if let Some(code) = parsed.format { let cur = wb.styles.get(style).num_fmt.as_str().to_string(); diff --git a/crates/engine/tests/percent_entry.rs b/crates/engine/tests/percent_entry.rs new file mode 100644 index 000000000..d47b7d11d --- /dev/null +++ b/crates/engine/tests/percent_entry.rs @@ -0,0 +1,20 @@ +//! Typing a bare number into a Percentage-formatted cell enters it as a percent (issue #1967). + +use gridcraft_engine::display::cell_text; +use serde_json::json; + +fn enter(input: &str) -> String { + let mut s = gridcraft_engine::Session::new(); + s.new_workbook(); + s.execute("home.numberFormat", json!({"range": "A1", "format": "Percentage"})).unwrap(); + s.execute("cell.set", json!({"cell": "A1", "input": input})).unwrap(); + let d = s.doc().unwrap(); + cell_text(&d.wb, d.wb.active().unwrap(), gridcraft_engine::core::CellRef::new(0, 0)) +} + +#[test] +fn bare_number_in_percent_cell_is_a_percent() { + assert_eq!(enter("3"), "3.00%"); + assert_eq!(enter("0.03"), "3.00%"); + assert_eq!(enter("3%"), "3.00%"); +} From b817c04a9bacf9c0d3a9d548002c88d52594b4ff Mon Sep 17 00:00:00 2001 From: Higgs32584 Date: Wed, 21 Oct 2026 21:06:51 -0400 Subject: [PATCH 15/18] clipboard: bound HTML colspan/rowspan expansion (#2171) Clipboard HTML is read only as far as the pasted text's shape (clamped to the sheet) and at most 1,000,000 cells; colspan/rowspan are clamped to the sheet extent before expanding, and rowspan now keeps later rows in their columns. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/clip_html.rs | 210 ++++++++++++++++++++++++++------- crates/engine/src/cmd/edit.rs | 5 +- crates/engine/src/tests.rs | 14 +++ 3 files changed, 185 insertions(+), 44 deletions(-) diff --git a/crates/engine/src/clip_html.rs b/crates/engine/src/clip_html.rs index ca7667694..044421a9d 100644 --- a/crates/engine/src/clip_html.rs +++ b/crates/engine/src/clip_html.rs @@ -11,6 +11,11 @@ const MAX_HTML: usize = 16 * 1024 * 1024; /// Widest `colspan` honoured (the sheet's column count). const MAX_SPAN: usize = gridcraft_core::MAX_COLS as usize; +/// Tallest `rowspan` honoured (the sheet's row count). +const MAX_ROW_SPAN: usize = gridcraft_core::MAX_ROWS as usize; +/// Most cells, span placeholders included, one paste reads from the HTML. Past it the rest of the +/// table is ignored: the HTML only refines cells of the tab-separated text, which stays the source. +const MAX_HTML_CELLS: usize = 1_000_000; /// One table cell: its visible text and the number the source recorded for it, if any. #[derive(Clone, Debug, Default, PartialEq)] @@ -19,25 +24,28 @@ pub(crate) struct HtmlCell { pub num: Option, } -/// The cells of the first table in `html`, row by row; a cell spanning columns is followed by -/// empty cells so positions line up with the tab-separated text. Empty when there is no table. -pub(crate) fn table_cells(html: &str) -> Vec> { - let mut rows: Vec> = Vec::new(); +/// The cells of the first table in `html`, row by row, read no further than `max_rows` rows of +/// `max_cols` cells (the pasted text's shape, itself clamped to the sheet) and [`MAX_HTML_CELLS`] +/// in all. A cell spanning columns is followed by empty cells, and a column covered by a `rowspan` +/// from above holds an empty cell, so positions line up with the tab-separated text. Empty when +/// there is no table. +pub(crate) fn table_cells(html: &str, max_rows: usize, max_cols: usize) -> Vec> { + let mut grid = Grid::new(max_rows, max_cols); if html.len() > MAX_HTML { - return rows; + return grid.rows; } - let mut cell: Option<(HtmlCell, usize)> = None; + let mut cell: Option = None; let mut in_table = false; let mut rest = html; - while !rest.is_empty() { + while !rest.is_empty() && !grid.full { let Some(lt) = rest.find('<') else { - if let Some((c, _)) = cell.as_mut() { - c.text.push_str(rest); + if let Some(c) = cell.as_mut() { + c.cell.text.push_str(rest); } break; }; - if let Some((c, _)) = cell.as_mut() { - c.text.push_str(rest.get(..lt).unwrap_or_default()); + if let Some(c) = cell.as_mut() { + c.cell.text.push_str(rest.get(..lt).unwrap_or_default()); } rest = rest.get(lt..).unwrap_or_default(); if let Some(after) = rest.strip_prefix("Rp10,000.00Name 2.50 & upx"#; - let rows = table_cells(excel); + let rows = table_cells(excel, 2, 3); assert_eq!(rows.len(), 2); assert_eq!(rows[0][0], HtmlCell { text: "Rp10,000.00".into(), num: Some(10000.0) }); assert_eq!(rows[0][1], HtmlCell { text: "Name".into(), num: None }); @@ -286,10 +376,10 @@ mod tests { let calc = r#"
Rp10,000.00
"#; - assert_eq!(table_cells(calc), vec![vec![HtmlCell { text: "Rp10,000.00".into(), num: Some(10000.0) }]]); + assert_eq!(table_cells(calc, 1, 1), vec![vec![HtmlCell { text: "Rp10,000.00".into(), num: Some(10000.0) }]]); let quoted_gt = r#"
1500
"#; - assert_eq!(table_cells(quoted_gt), vec![vec![HtmlCell { text: "1500".into(), num: Some(1500.0) }]]); + assert_eq!(table_cells(quoted_gt, 1, 1), vec![vec![HtmlCell { text: "1500".into(), num: Some(1500.0) }]]); } #[test] @@ -306,10 +396,46 @@ mod tests { "