From 68f956a4102daf17f4272608c9f1869efb212114 Mon Sep 17 00:00:00 2001 From: dantebertuzzi Date: Tue, 20 Oct 2026 19:59:48 -0300 Subject: [PATCH] Number Stored as Text: green indicator, warning button and Convert to Number (#1994) Numbers stored as text are skipped by SUM and friends, and GridCraft gave no hint. Like Excel's background error checking, a constant whose text would be a number if typed now gets a green triangle in its top-left corner; with such a cell active, a warning button beside it opens a menu naming the problem with Convert to Number, which converts every flagged cell in the selection as one undo step. - core: `parse::number_stored_as_text` (signs, thousands separators, currency, percent, exponent, one parenthesised negative). Dates, times and fractions kept as text are not flagged, as in Excel. - engine: `cmd::formulas::number_stored_as_text(cell)` (constants only, not formula results) and `formulas.convertToNumber {range?}`, which keeps each cell's format. - ui: the triangle in the grid and the warning button with its menu; pt-BR and German strings. Co-Authored-By: Claude Opus 5.5 --- crates/core/src/parse.rs | 22 +++++++ crates/engine/src/cmd/formulas.rs | 45 ++++++++++++++- crates/engine/src/tests.rs | 29 ++++++++++ crates/ui-egui/src/grid.rs | 57 +++++++++++++++++++ crates/ui-egui/src/i18n/de.rs | 2 + crates/ui-egui/src/i18n/pt.rs | 3 + crates/ui-egui/tests/number_stored_as_text.rs | 44 ++++++++++++++ docs/ui-parity.md | 3 +- 8 files changed, 203 insertions(+), 2 deletions(-) create mode 100644 crates/ui-egui/tests/number_stored_as_text.rs diff --git a/crates/core/src/parse.rs b/crates/core/src/parse.rs index b465beaf9..d1435f37b 100644 --- a/crates/core/src/parse.rs +++ b/crates/core/src/parse.rs @@ -49,6 +49,17 @@ pub fn parse_number_text_in(s: &str, sys: DateSystem) -> Option { parse_numeric(s.trim(), sys).map(|(n, _)| n) } +/// Text that Excel's "Number Stored as Text" check flags: it would be a number if typed (signs, +/// thousands separators, currency, percent, exponent, a parenthesised negative). Dates, times and +/// fractions kept as text are not flagged, as in Excel. +pub fn number_stored_as_text(s: &str) -> Option { + let t = s.trim(); + match t.strip_prefix('(').and_then(|x| x.strip_suffix(')')) { + Some(inner) => parse_plain(inner.trim()).map(|(n, _)| -n), + None => parse_plain(t).map(|(n, _)| n), + } +} + fn parse_numeric(t: &str, sys: DateSystem) -> Option<(f64, Option<&'static str>)> { if t.is_empty() { return None; @@ -398,4 +409,15 @@ mod tests { assert_eq!(parse_input("2/29/1900", DateSystem::D1904).value, Value::text("2/29/1900")); assert_eq!(parse_input("2/29/1904", DateSystem::D1904).value, Value::Number(59.0)); } + + #[test] + fn numbers_stored_as_text() { + for (text, n) in [("123", 123.0), (" 42 ", 42.0), ("-1,000.5", -1000.5), ("$3", 3.0), ("50%", 0.5), ("1E3", 1000.0), ("(100)", -100.0)] { + assert_eq!(number_stored_as_text(text), Some(n), "{text}"); + } + // Dates, times, fractions, words and blanks are not flagged. + for text in ["3/4/2026", "10:30", "1 1/2", "abc", "12abc", "", " ", "TRUE", "#N/A", "((1))"] { + assert_eq!(number_stored_as_text(text), None, "{text}"); + } + } } diff --git a/crates/engine/src/cmd/formulas.rs b/crates/engine/src/cmd/formulas.rs index 454c545d0..173493b56 100644 --- a/crates/engine/src/cmd/formulas.rs +++ b/crates/engine/src/cmd/formulas.rs @@ -1,6 +1,6 @@ //! Formulas tab: AutoSum, Insert Function, names, auditing, calculation. -use gridcraft_core::{CellRef, RangeRef}; +use gridcraft_core::{CellRef, RangeRef, Value}; use gridcraft_model::{CalcMode, DefinedName, Formula}; use serde_json::{Value as Json, json}; @@ -51,6 +51,15 @@ pub fn specs() -> Vec { cmd!(query "formulas.traceDependents", "Trace Dependents", ["Formulas", "Formula Auditing"], None, "{cell?} → cells", has_doc, trace_dependents), cmd!(noundo "formulas.removeArrows", "Remove Arrows", ["Formulas", "Formula Auditing"], None, "{}", has_doc, |_, _| ok()), cmd!("formulas.showFormulas", "Show Formulas", ["Formulas", "Formula Auditing"], Some("Ctrl+`"), "{on?}", has_doc, show_formulas), + cmd!( + "formulas.convertToNumber", + "Convert to Number", + [], + None, + "{range?} → {converted} (numbers stored as text in the range or selection become numbers)", + has_doc, + convert_to_number + ), cmd!(query "formulas.errorChecking", "Error Checking", ["Formulas", "Formula Auditing"], None, "{} → cells with errors", has_doc, error_checking), cmd!(query "formulas.evaluateFormula", "Evaluate Formula", ["Formulas", "Formula Auditing"], None, "{cell?, formula?} → steps", has_doc, evaluate_formula), cmd!(query "formulas.evaluate", "Evaluate", [], None, "{formula: \"=SUM(A1:A3)\", cell?} → value without changing the sheet", has_doc, evaluate), @@ -489,6 +498,40 @@ fn show_formulas(s: &mut Session, p: &Json) -> Result { }) } +/// The number a cell's text stands for when Excel's background check flags it as a number stored +/// as text: a constant (not a formula's result) that would be a number if typed. +pub fn number_stored_as_text(cell: &gridcraft_model::Cell) -> Option { + match &cell.value { + Value::Text(t) if cell.formula.is_none() => gridcraft_core::parse::number_stored_as_text(t), + _ => None, + } +} + +/// Convert to Number: each number stored as text in the range becomes that number, keeping the +/// cell's format. +fn convert_to_number(s: &mut Session, p: &Json) -> Result { + let sheet = target_sheet(s, p)?; + let ranges = target_ranges(s, p)?; + edit(s, |cx| { + let mut converted = 0; + for r in &ranges { + let Some(r) = cx.wb.sheet(sheet).and_then(|sh| sh.used_range()).and_then(|u| r.intersection(&u)) else { continue }; + let found: Vec<(CellRef, f64)> = cx + .wb + .sheet(sheet) + .map(|sh| sh.cells.iter_range(r).filter_map(|(c, x)| number_stored_as_text(x).map(|n| (c, n))).collect()) + .unwrap_or_default(); + let sh = cx.sheet_mut(sheet)?; + for (c, n) in &found { + sh.set_value(*c, Value::number(*n)); + } + cx.changed.extend(found.iter().map(|(c, _)| (sheet, *c))); + converted += found.len(); + } + Ok(json!({"converted": converted})) + }) +} + fn error_checking(s: &mut Session, _: &Json) -> Result { let d = s.doc()?; let mut out = Vec::new(); diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 2ac290654..30194b54f 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -3344,3 +3344,32 @@ fn csv_as_text_keeps_identifier_columns_as_written() { let saved = s.execute("file.saveBytes", json!({"format": "csv"})).unwrap(); assert_eq!(crate::io::base64_decode(saved["base64"].as_str().unwrap()).unwrap(), csv.replace('\n', "\r\n").as_bytes()); } + +#[test] +fn convert_to_number_turns_numbers_stored_as_text_into_numbers() { + let mut s = s(); + let value = |s: &mut Session, cell: &str| s.execute("cell.get", json!({"cell": cell})).unwrap()["value"].clone(); + for (cell, input) in [("A1", "'123"), ("A2", "'1,000.5"), ("A3", "'abc"), ("A4", "'3/4/2026"), ("A5", "=\"7\"")] { + s.execute("cell.set", json!({"cell": cell, "input": input})).unwrap(); + } + s.execute("home.bold", json!({"range": "A1", "on": true})).unwrap(); + let r = s.execute("formulas.convertToNumber", json!({"range": "A1:A5"})).unwrap(); + assert_eq!(r["converted"], 2); + assert_eq!(value(&mut s, "A1"), json!(123.0)); + assert_eq!(value(&mut s, "A2"), json!(1000.5)); + // Words, date text and a formula's text result stay as they are. + assert_eq!(value(&mut s, "A3"), json!("abc")); + assert_eq!(value(&mut s, "A4"), json!("3/4/2026")); + assert_eq!(value(&mut s, "A5"), json!("7")); + assert_eq!(s.execute("cell.get", json!({"cell": "A1"})).unwrap()["style"]["font"]["bold"], true, "the format is kept"); + // SUM now counts them; undo brings the text back in one step. + s.execute("cell.set", json!({"cell": "B1", "input": "=SUM(A1:A2)"})).unwrap(); + assert_eq!(value(&mut s, "B1"), json!(1123.5)); + s.execute("edit.undo", json!({})).unwrap(); + s.execute("edit.undo", json!({})).unwrap(); + assert_eq!(value(&mut s, "A1"), json!("123")); + // With no range it works on the selection. + s.execute("selection.set", json!({"range": "A2"})).unwrap(); + assert_eq!(s.execute("formulas.convertToNumber", json!({})).unwrap()["converted"], 1); + assert_eq!(value(&mut s, "A1"), json!("123")); +} diff --git a/crates/ui-egui/src/grid.rs b/crates/ui-egui/src/grid.rs index 106bc6684..6f07144e0 100644 --- a/crates/ui-egui/src/grid.rs +++ b/crates/ui-egui/src/grid.rs @@ -790,6 +790,7 @@ pub fn show(app: &mut SheetApp, ui: &mut egui::Ui) { list_picker(app, ui, &geo, previous_popups.1 == app.grid.list_picker); header_menu(app, ui, previous_popups.2 == app.grid.header_menu); context_menu(app, ui); + error_check_tag(app, ui, &geo, sh); let track_x = if rtl { (available.left(), rect.left()) } else { (rect.right(), available.right()) }; vertical_scrollbar(app, ui, sh, &geo, track_x, max_y, &t); if app.session.clipboard.is_some() { @@ -1383,6 +1384,15 @@ pub(crate) fn paint_quadrant( Stroke::NONE, )); } + // Background error checking: a number stored as text gets Excel's green corner (#1994). + if !sh.show_formulas && sh.cell(*c).and_then(gridcraft_engine::cmd::formulas::number_stored_as_text).is_some() { + let s = 6.0 * z.max(0.7); + p.add(egui::Shape::convex_polygon( + vec![rect.left_top(), pos2(rect.left() + s, rect.top()), pos2(rect.left(), rect.top() + s)], + ERROR_CHECK_GREEN, + Stroke::NONE, + )); + } } // Sparklines. for sp in &sh.sparklines { @@ -2970,6 +2980,53 @@ fn context_menu(app: &mut SheetApp, ui: &mut egui::Ui) { } } +/// Excel's background error-checking indicator colour. +const ERROR_CHECK_GREEN: Color32 = Color32::from_rgb(0x1E, 0x8E, 0x3E); + +/// The warning button beside an active cell holding a number stored as text, as in Excel: its +/// menu names the problem and offers Convert to Number for the selection (#1994). +fn error_check_tag(app: &mut SheetApp, ui: &mut egui::Ui, geo: &Geo, sh: &Sheet) { + if app.editor.is_some() || app.dialog.is_some() || sh.show_formulas || app.grid.drag != Drag::None { + return; + } + let Some(active) = app.session.active().map(|d| d.selection.active) else { return }; + if sh.cell(active).and_then(gridcraft_engine::cmd::formulas::number_stored_as_text).is_none() { + return; + } + let cell = geo.cell_rect(sh, active); + let size = vec2(18.0, 18.0); + // Left of the cell, or right of it when that would cover the row headings. + let left = if cell.left() - size.x - 2.0 >= geo.cells.left() { cell.left() - size.x - 2.0 } else { cell.right() + 2.0 }; + let tag = geo.mirror_rect(Rect::from_min_size(pos2(left, cell.top()), size)); + if !geo.cells.intersects(tag) { + return; + } + let lang = crate::i18n::current(ui.ctx()); + let resp = ui.interact(tag, egui::Id::new("gridcraft.error_check_tag"), Sense::click()).on_hover_text(lang.tr("Number Stored as Text")); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, lang.tr("Error checking options"))); + let t = Tokens::get(ui.ctx()); + let p = ui.painter(); + p.rect_filled(tag, 3.0, if resp.hovered() { t.hover } else { t.window }); + p.rect_stroke(tag, 3.0, Stroke::new(1.0, t.separator), egui::StrokeKind::Inside); + // A warning sign: an amber diamond with an exclamation mark. + let c = tag.center(); + let r = 6.0; + p.add(egui::Shape::convex_polygon( + vec![pos2(c.x, c.y - r), pos2(c.x + r, c.y), pos2(c.x, c.y + r), pos2(c.x - r, c.y)], + Color32::from_rgb(0xF2, 0xB7, 0x05), + Stroke::NONE, + )); + p.text(c, egui::Align2::CENTER_CENTER, "!", egui::FontId::proportional(10.0), Color32::BLACK); + egui::Popup::menu(&resp).show(|ui| { + ui.label(egui::RichText::new(lang.tr("Number Stored as Text")).strong()); + ui.separator(); + if ui.button(lang.tr("Convert to Number")).clicked() { + app.run_or_alert("formulas.convertToNumber", json!({})); + ui.close(); + } + }); +} + /// Used by `EditState` users to keep the active cell visible while pointing. pub fn point_move(app: &mut SheetApp, ed: &mut EditState, dr: i64, dc: i64, extend: bool) { let Some(d) = app.session.active() else { return }; diff --git a/crates/ui-egui/src/i18n/de.rs b/crates/ui-egui/src/i18n/de.rs index 371ea7981..6ad7993e6 100644 --- a/crates/ui-egui/src/i18n/de.rs +++ b/crates/ui-egui/src/i18n/de.rs @@ -61,6 +61,8 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Insert" => "Einfügen", "Insert Cells…" => "Zellen einfügen…", "Delete" => "Löschen", + "Number Stored as Text" => "Als Text gespeicherte Zahl", + "Convert to Number" => "In eine Zahl umwandeln", "Delete Cells…" => "Zellen löschen…", "Insert Sheet Rows" => "Blattzeilen einfügen", "Insert Sheet Columns" => "Blattspalten einfügen", diff --git a/crates/ui-egui/src/i18n/pt.rs b/crates/ui-egui/src/i18n/pt.rs index fb9236cdb..5741ceb4e 100644 --- a/crates/ui-egui/src/i18n/pt.rs +++ b/crates/ui-egui/src/i18n/pt.rs @@ -426,6 +426,9 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Select a chart first." => "Selecione um gráfico primeiro.", "Text Box" => "Caixa de Texto", "Select Data" => "Selecionar Dados", + "Number Stored as Text" => "Número Armazenado como Texto", + "Convert to Number" => "Converter em Número", + "Error checking options" => "Opções de verificação de erros", "Row Height" => "Altura da Linha", "Column Width" => "Largura da Coluna", "Standard Width" => "Largura Padrão", diff --git a/crates/ui-egui/tests/number_stored_as_text.rs b/crates/ui-egui/tests/number_stored_as_text.rs new file mode 100644 index 000000000..8664cc6d3 --- /dev/null +++ b/crates/ui-egui/tests/number_stored_as_text.rs @@ -0,0 +1,44 @@ +//! A number stored as text gets Excel's green corner and a warning button whose menu converts it (#1994). + +use egui_kittest::kittest::Queryable as _; +use gridcraft_engine::Session; +use gridcraft_ui_egui::SheetApp; +use serde_json::json; + +type Harness = egui_kittest::Harness<'static, SheetApp>; + +fn harness(active: &str) -> Harness { + let mut s = Session::new(); + s.new_workbook(); + s.execute("cell.set", json!({"cell": "B2", "input": "'123"})).unwrap(); + s.execute("cell.set", json!({"cell": "B3", "input": "'abc"})).unwrap(); + s.execute("selection.set", json!({"cell": active})).unwrap(); + let mut h = Harness::builder().with_size(egui::vec2(1200.0, 800.0)).build_ui_state( + |ui, app: &mut SheetApp| { + let ctx = ui.ctx().clone(); + app.logic(&ctx); + app.ui(ui); + }, + SheetApp::new(s, Default::default()), + ); + h.run_steps(4); + h +} + +#[test] +fn the_warning_button_converts_a_number_stored_as_text() { + let mut h = harness("B2"); + h.get_by_label("Error checking options").click(); + h.run_steps(3); + h.get_by_label("Convert to Number").click(); + h.run_steps(3); + let v = h.state_mut().session.execute("cell.get", json!({"cell": "B2"})).unwrap()["value"].clone(); + assert_eq!(v, json!(123.0)); + assert!(h.query_by_label("Error checking options").is_none(), "the button goes once the number is a number"); +} + +#[test] +fn text_that_is_not_a_number_gets_no_button() { + let h = harness("B3"); + assert!(h.query_by_label("Error checking options").is_none()); +} diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 34cb502f5..abf5f669e 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-19 · **Change:** minor (Ctrl+Enter fill gives each cell the automatic number format typing gives it; keytip letters typed with Alt held, #1846) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-21 · **Change:** minor (Number Stored as Text indicator and Convert to Number) · **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-21 | minor | Background error checking, first part (#1994): a constant whose text would be a number if typed (signs, thousands, currency, percent, exponent, parenthesised negative) gets Excel's green corner triangle; the active cell then shows a warning button whose menu names the problem and offers Convert to Number for the selection (`formulas.convertToNumber`). Dates, times and fractions kept as text and formula results are not flagged, as in Excel | | 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-18 | minor | Keytip letters typed with left Alt held continue the sequence (Alt+H, O, A / Alt+H, O, I ran nothing before: Alt+letter cancelled keytips); other Alt combinations, AltGr and macOS Option are unchanged (#1846) | | 2026-10-12 | minor | The File view's recent list tells when each workbook was last changed, read off the UI thread |