mirror of
https://github.com/storytold/gridcraft.git
synced 2026-10-24 10:46:50 -06:00
Merge PR #2095: Number Stored as Text: green indicator, warning button and Convert to Number (#1994)
crates/ui-egui/src/grid.rs: kept object_menu (2084) and error_check_tag (2095), both fns and both calls. crates/ui-egui/src/i18n/de.rs, pt.rs: union of the entries. docs/ui-parity.md: one status line naming both changes, both history rows.
This commit is contained in:
@@ -49,6 +49,17 @@ pub fn parse_number_text_in(s: &str, sys: DateSystem) -> Option<f64> {
|
||||
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<f64> {
|
||||
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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -53,6 +53,15 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
cmd!(noundo "formulas.selectDependents", "Select Dependents", ["Formulas", "Formula Auditing"], Some("Ctrl+]"), "{cell?, all?: true = every level (Ctrl+Shift+])}", has_doc, select_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),
|
||||
@@ -567,6 +576,40 @@ fn show_formulas(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
})
|
||||
}
|
||||
|
||||
/// 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<f64> {
|
||||
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<Json> {
|
||||
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<Json> {
|
||||
let d = s.doc()?;
|
||||
let mut out = Vec::new();
|
||||
|
||||
@@ -3359,3 +3359,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"));
|
||||
}
|
||||
|
||||
@@ -783,6 +783,7 @@ pub fn show(app: &mut SheetApp, ui: &mut egui::Ui) {
|
||||
header_menu(app, ui, previous_popups.2 == app.grid.header_menu);
|
||||
context_menu(app, ui);
|
||||
object_menu(app, ui);
|
||||
error_check_tag(app, ui, &geo, sh);
|
||||
let track_x = if rtl { (available.left(), rect.left()) } else { (rect.right(), available.right()) };
|
||||
// The track starts below the column headings and frozen rows; the bar's column beside them
|
||||
// takes its colour too instead of showing a light square in dark mode.
|
||||
@@ -1379,6 +1380,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 {
|
||||
@@ -3032,6 +3042,53 @@ fn object_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 };
|
||||
|
||||
@@ -63,6 +63,8 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
|
||||
"Delete" => "Löschen",
|
||||
"Select Data…" => "Daten auswählen…",
|
||||
"Format Chart Area…" => "Diagrammbereich formatieren…",
|
||||
"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",
|
||||
|
||||
@@ -428,6 +428,9 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
|
||||
"Select Data" => "Selecionar Dados",
|
||||
"Select Data…" => "Selecionar Dados…",
|
||||
"Format Chart Area…" => "Formatar Área do Gráfico…",
|
||||
"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",
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
+2
-1
@@ -1,6 +1,6 @@
|
||||
# UI parity
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-20 · **Change:** minor (Esc and Enter close a data validation alert over the cell editor; 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 (Esc and Enter close a data validation alert over the cell editor; Ctrl+Enter fill gives each cell the automatic number format typing gives it; keytip letters typed with Alt held, #1846; 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-20 | minor | Esc and Enter close a message over the cell editor, such as a data validation alert, and the entry stays open to fix, with the keyboard back in it. Before, the editor took them (the first Esc cancelled the edit behind the alert and only the second closed it), and after the message typing went nowhere until the cell was clicked |
|
||||
| 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) |
|
||||
|
||||
Reference in New Issue
Block a user