diff --git a/README.md b/README.md index d416eba..b91db47 100644 --- a/README.md +++ b/README.md @@ -107,6 +107,9 @@ cargo run --release -p gridcraft -- --sample sales --control 7979 # + JSON con cargo xtask ci # fmt, clippy, tests, assets, layering, wasm ``` +Choose Español under View → Interface language for [Spanish formula names, decimal commas, and semicolon arguments](docs/formula-input.md). +Workbooks, clipboard exchange, and programmatic APIs retain canonical English syntax. + ### Web ```sh diff --git a/crates/formula/src/input.rs b/crates/formula/src/input.rs new file mode 100644 index 0000000..8568023 --- /dev/null +++ b/crates/formula/src/input.rs @@ -0,0 +1,204 @@ +//! Localized aliases accepted at user-entry boundaries; file syntax stays English. + +use crate::{Expr, ParseError, parser}; + +/// Formula-entry syntax selected by the interface language. Other interface languages +/// currently use the canonical English function table until a translation table is supplied. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum FormulaLocale { + #[default] + En, + Es, +} + +const SPANISH: &[(&str, &str)] = &[ + ("SUM", "SUMA"), + ("AVERAGE", "PROMEDIO"), + ("IF", "SI"), + ("COUNT", "CONTAR"), + ("SUMIF", "SUMAR.SI"), + ("COUNTIF", "CONTAR.SI"), + ("VLOOKUP", "BUSCARV"), + ("TODAY", "HOY"), + ("TRUE", "VERDADERO"), + ("FALSE", "FALSO"), +]; + +impl FormulaLocale { + pub fn list_separator(self) -> char { + if self == Self::Es { ';' } else { ',' } + } + pub fn decimal_separator(self) -> char { + if self == Self::Es { ',' } else { '.' } + } + pub fn array_column_separator(self) -> char { + if self == Self::Es { '\\' } else { ',' } + } + pub fn function_name(self, canonical: &str) -> &str { + if self == Self::Es { + SPANISH.iter().find(|(en, _)| en.eq_ignore_ascii_case(canonical)).map(|(_, es)| *es).unwrap_or(canonical) + } else { + canonical + } + } + pub fn canonical_name(self, localized: &str) -> &str { + if self == Self::Es { + SPANISH.iter().find(|(_, es)| es.eq_ignore_ascii_case(localized)).map(|(en, _)| *en).unwrap_or(localized) + } else { + localized + } + } +} + +/// Parses UI input using the selected function table and separators. Existing names and +/// LET/LAMBDA bindings take precedence over aliases. File and API syntax uses [`crate::parse`]. +pub fn parse_input(src: &str, locale: FormulaLocale, known_name: impl Fn(&str) -> bool) -> Result { + let mut expr = parser::parse_syntax(src, locale)?; + if locale == FormulaLocale::Es { + normalize(&mut expr, &known_name, &mut Vec::new(), 0, false)?; + } + Ok(expr) +} + +/// Prints canonical expressions for editing. Name collisions retain the canonical spelling +/// so displaying then committing a formula never changes a defined-name reference. +pub fn print_input(expr: &Expr, locale: FormulaLocale, known_name: impl Fn(&str) -> bool) -> Result { + // Check before cloning/printing: a flat operator chain can produce a deep AST even + // though the parser itself did not recurse deeply. + let mut pending = vec![(expr, 0usize)]; + while let Some((node, depth)) = pending.pop() { + if depth > parser::MAX_DEPTH { + return Err(ParseError { msg: "formula is nested too deeply".into(), pos: 0 }); + } + match node { + Expr::Unary(_, child) | Expr::Paren(child) => pending.push((child, depth + 1)), + Expr::Binary(_, left, right) => { + pending.push((left, depth + 1)); + pending.push((right, depth + 1)); + } + Expr::Call(_, args) => pending.extend(args.iter().map(|e| (e, depth + 1))), + Expr::Invoke(callee, args) => { + pending.push((callee, depth + 1)); + pending.extend(args.iter().map(|e| (e, depth + 1))); + } + Expr::Array(rows) => pending.extend(rows.iter().flatten().map(|e| (e, depth + 1))), + _ => {} + } + } + if locale == FormulaLocale::En { + return Ok(crate::print(expr)); + } + let mut expr = expr.clone(); + normalize(&mut expr, &known_name, &mut Vec::new(), 0, true)?; + let canonical = crate::print(&expr); + let tokens = crate::lexer::tokenize(&canonical).map_err(|e| ParseError { msg: e.msg, pos: e.pos })?; + let mut out = String::new(); + let mut array_depth = 0usize; + for token in tokens { + use crate::lexer::Tok; + let text = canonical.get(token.start..token.end).unwrap_or(""); + match token.tok { + Tok::LBrace => { + array_depth += 1; + out.push_str(text); + } + Tok::RBrace => { + array_depth = array_depth.saturating_sub(1); + out.push_str(text); + } + Tok::Number(_) => out.push_str(&text.replace('.', &locale.decimal_separator().to_string())), + Tok::Comma => out.push(if array_depth > 0 { locale.array_column_separator() } else { locale.list_separator() }), + _ => out.push_str(text), + } + } + Ok(out) +} + +pub(crate) fn boolean_alias(name: &str) -> Option { + if name.eq_ignore_ascii_case("VERDADERO") { + Some(true) + } else if name.eq_ignore_ascii_case("FALSO") { + Some(false) + } else { + None + } +} + +fn normalize(expr: &mut Expr, known_name: &impl Fn(&str) -> bool, bindings: &mut Vec, depth: usize, display: bool) -> Result<(), ParseError> { + if depth > parser::MAX_DEPTH { + return Err(ParseError { msg: "formula is nested too deeply".into(), pos: 0 }); + } + let bound = |name: &str, bindings: &[String]| bindings.iter().rev().any(|n| n.eq_ignore_ascii_case(name)) || known_name(name); + match expr { + Expr::Bool(value) if display => { + let alias = if *value { "VERDADERO" } else { "FALSO" }; + if !bound(alias, bindings) { + *expr = Expr::Name(alias.into()); + } + } + Expr::Name(name) if !display && !bound(name, bindings) => { + if let Some(value) = boolean_alias(name) { + *expr = Expr::Bool(value); + } + } + Expr::Call(name, args) if name == "LET" && args.len() >= 3 && !args.len().is_multiple_of(2) => { + let saved = bindings.len(); + if let Some((body, pairs)) = args.split_last_mut() { + for pair in pairs.chunks_mut(2) { + if let [declaration, value] = pair { + // The new name is not in scope in its own value expression. + normalize(value, known_name, bindings, depth + 1, display)?; + if let Expr::Name(name) = declaration { + bindings.push(name.clone()); + } + } + } + normalize(body, known_name, bindings, depth + 1, display)?; + } + bindings.truncate(saved); + } + Expr::Call(name, args) if name == "LAMBDA" => { + let saved = bindings.len(); + if let Some((body, params)) = args.split_last_mut() { + for param in params { + if let Expr::Name(name) = param { + bindings.push(name.clone()); + } + } + normalize(body, known_name, bindings, depth + 1, display)?; + } + bindings.truncate(saved); + } + Expr::Call(name, args) => { + let alias = if display { FormulaLocale::Es.function_name(name) } else { FormulaLocale::Es.canonical_name(name) }; + if !bound(name, bindings) && (!display || !bound(alias, bindings)) { + *name = alias.to_string(); + } + for arg in args { + normalize(arg, known_name, bindings, depth + 1, display)?; + } + } + Expr::Invoke(callee, args) => { + normalize(callee, known_name, bindings, depth + 1, display)?; + for arg in args { + normalize(arg, known_name, bindings, depth + 1, display)?; + } + } + Expr::Unary(_, child) | Expr::Paren(child) => normalize(child, known_name, bindings, depth + 1, display)?, + Expr::Binary(_, left, right) => { + normalize(left, known_name, bindings, depth + 1, display)?; + normalize(right, known_name, bindings, depth + 1, display)?; + } + Expr::Array(rows) if display => { + for row in rows { + for child in row { + normalize(child, known_name, bindings, depth + 1, display)?; + } + } + } + // Array elements have already been parsed as literals. Sheet/structured references + // contain user-defined labels and must not be translated. + _ => {} + } + Ok(()) +} diff --git a/crates/formula/src/lexer.rs b/crates/formula/src/lexer.rs index 14b45d9..a401333 100644 --- a/crates/formula/src/lexer.rs +++ b/crates/formula/src/lexer.rs @@ -1,4 +1,4 @@ -//! Tokenizer for Excel formulas (A1 style, en-US separators). +//! Tokenizer for Excel formulas (A1 style, canonical or selected UI separators). //! //! Token spans are byte offsets into the source, which the editor uses to colour references. @@ -51,12 +51,18 @@ fn is_word_char(c: char) -> bool { /// Tokenizes formula text (without the leading `=`). pub fn tokenize(src: &str) -> Result, LexError> { + tokenize_locale(src, crate::FormulaLocale::En) +} + +/// Tokenizes UI syntax while retaining byte spans in the original text. +pub fn tokenize_locale(src: &str, locale: crate::FormulaLocale) -> Result, LexError> { let chars: Vec<(usize, char)> = src.char_indices().collect(); let n = chars.len(); let pos_of = |i: usize| chars.get(i).map(|c| c.0).unwrap_or(src.len()); let ch = |i: usize| chars.get(i).map(|c| c.1); let mut out = Vec::new(); let mut i = 0; + let mut array_depth = 0usize; while i < n { let start = pos_of(i); let Some(c) = ch(i) else { break }; @@ -172,7 +178,7 @@ pub fn tokenize(src: &str) -> Result, LexError> { push(&mut out, Tok::Struct(inner), j); i = j; } - '0'..='9' | '.' if c != '.' || ch(i + 1).is_some_and(|d| d.is_ascii_digit()) => { + c if c.is_ascii_digit() || (c == locale.decimal_separator() && ch(i + 1).is_some_and(|d| d.is_ascii_digit())) => { // Number, unless it is a row range like 1:3 (handled by parser via Word) — numbers // followed by ':' and digits are row refs: lex as Word. let mut j = i; @@ -193,7 +199,7 @@ pub fn tokenize(src: &str) -> Result, LexError> { i = j; continue; } - if ch(j) == Some('.') { + if ch(j) == Some(locale.decimal_separator()) { j += 1; while ch(j).is_some_and(|d| d.is_ascii_digit()) { j += 1; @@ -212,7 +218,10 @@ pub fn tokenize(src: &str) -> Result, LexError> { } } let text = src.get(start..pos_of(j)).unwrap_or(""); - let v: f64 = text.parse().map_err(|_| LexError { msg: format!("bad number `{text}`"), pos: start })?; + let v: f64 = text + .replace(locale.decimal_separator(), ".") + .parse() + .map_err(|_| LexError { msg: format!("bad number `{text}`"), pos: start })?; push(&mut out, Tok::Number(v), j); i = j; } @@ -225,14 +234,16 @@ pub fn tokenize(src: &str) -> Result, LexError> { i += 1; } '{' => { + array_depth += 1; push(&mut out, Tok::LBrace, i + 1); i += 1; } '}' => { + array_depth = array_depth.saturating_sub(1); push(&mut out, Tok::RBrace, i + 1); i += 1; } - ',' => { + c if (array_depth == 0 && c == locale.list_separator()) || (array_depth > 0 && c == locale.array_column_separator()) => { push(&mut out, Tok::Comma, i + 1); i += 1; } @@ -273,7 +284,7 @@ pub fn tokenize(src: &str) -> Result, LexError> { } c if is_word_char(c) => { let mut j = i; - while ch(j).is_some_and(is_word_char) { + while ch(j).is_some_and(|c| is_word_char(c) && !(array_depth > 0 && c == locale.array_column_separator())) { j += 1; } let w: String = chars.get(i..j).map(|s| s.iter().map(|c| c.1).collect()).unwrap_or_default(); @@ -283,13 +294,13 @@ pub fn tokenize(src: &str) -> Result, LexError> { } else if ch(j) == Some(':') && { // Sheet1:Sheet3!A1 — look ahead for word then '!' let mut k = j + 1; - while ch(k).is_some_and(is_word_char) { + while ch(k).is_some_and(|c| is_word_char(c) && !(array_depth > 0 && c == locale.array_column_separator())) { k += 1; } k > j + 1 && ch(k) == Some('!') } { let mut k = j + 1; - while ch(k).is_some_and(is_word_char) { + while ch(k).is_some_and(|c| is_word_char(c) && !(array_depth > 0 && c == locale.array_column_separator())) { k += 1; } let w2: String = chars.get(j + 1..k).map(|s| s.iter().map(|c| c.1).collect()).unwrap_or_default(); diff --git a/crates/formula/src/lib.rs b/crates/formula/src/lib.rs index c929081..c404332 100644 --- a/crates/formula/src/lib.rs +++ b/crates/formula/src/lib.rs @@ -5,11 +5,13 @@ pub mod adjust; pub mod ast; +mod input; pub mod lexer; pub mod parser; pub mod printer; pub use ast::{Anchor, BinOp, Expr, RefKind, Reference, SheetSel, StructItem, StructRef, UnOp}; +pub use input::{FormulaLocale, parse_input, print_input}; pub use parser::{ParseError, normalize_function_name, parse}; pub use printer::{print, print_r1c1, quote_sheet}; diff --git a/crates/formula/src/parser.rs b/crates/formula/src/parser.rs index b92e2e6..754e986 100644 --- a/crates/formula/src/parser.rs +++ b/crates/formula/src/parser.rs @@ -6,7 +6,7 @@ use gridcraft_core::addr::{letters_to_col, parse_a1_prefix}; use gridcraft_core::{CellError, MAX_ROWS}; use crate::ast::*; -use crate::lexer::{Tok, Token, tokenize}; +use crate::lexer::{Tok, Token, tokenize_locale}; #[derive(Clone, Debug, PartialEq, thiserror::Error)] #[error("{msg}")] @@ -15,15 +15,19 @@ pub struct ParseError { pub pos: usize, } -const MAX_DEPTH: usize = 256; +pub(crate) const MAX_DEPTH: usize = 256; /// Parses formula text. A leading `=` (or `+`/`-` as Lotus users type) is accepted and skipped /// for `=`. pub fn parse(src: &str) -> Result { + parse_syntax(src, crate::FormulaLocale::En) +} + +pub(crate) fn parse_syntax(src: &str, locale: crate::FormulaLocale) -> Result { let body = src.strip_prefix('=').unwrap_or(src); - let toks = tokenize(body).map_err(|e| ParseError { msg: e.msg, pos: e.pos })?; + let toks = tokenize_locale(body, locale).map_err(|e| ParseError { msg: e.msg, pos: e.pos })?; let toks = significant_spaces(toks); - let mut p = Parser { toks, i: 0, depth: 0 }; + let mut p = Parser { toks, i: 0, depth: 0, input: locale == crate::FormulaLocale::Es }; let e = p.expr(0, false)?; match p.peek() { Tok::Eof => Ok(e), @@ -62,6 +66,7 @@ struct Parser { toks: Vec, i: usize, depth: usize, + input: bool, } fn word_is_cols(w: &str) -> Option<(u32, bool)> { @@ -324,6 +329,9 @@ impl Parser { }, Tok::Text(s) => Expr::Text(Arc::from(s)), Tok::Bool(b) => Expr::Bool(b), + Tok::Word(w) if self.input => crate::input::boolean_alias(&w) + .map(Expr::Bool) + .ok_or_else(|| self.err(format!("array constants may only contain constants, found {}", describe(&Tok::Word(w)))))?, Tok::Error(e) => Expr::Error(e), t => return Err(self.err(format!("array constants may only contain constants, found {}", describe(&t)))), }; diff --git a/crates/formula/tests/spanish_input.rs b/crates/formula/tests/spanish_input.rs new file mode 100644 index 0000000..e68de82 --- /dev/null +++ b/crates/formula/tests/spanish_input.rs @@ -0,0 +1,91 @@ +use gridcraft_formula::{FormulaLocale, parse, parse_input, print, print_input}; + +fn input(src: &str) -> String { + print(&parse_input(src, FormulaLocale::Es, |_| false).unwrap()) +} + +#[test] +fn spanish_aliases_and_argument_separators() { + for (src, expected) in [ + ("=suma(A1:A10)", "SUM(A1:A10)"), + ("PROMEDIO(A1:A10)", "AVERAGE(A1:A10)"), + ("SI(A1>0;\"Sí\";\"No\")", "IF(A1>0,\"Sí\",\"No\")"), + ("CONTAR(A1:A10)", "COUNT(A1:A10)"), + ("SUMAR.SI(A1:A10;\">0\")", "SUMIF(A1:A10,\">0\")"), + ("CONTAR.SI(A1:A10;\">0\")", "COUNTIF(A1:A10,\">0\")"), + ("BUSCARV(A2;D:E;2;FALSO)", "VLOOKUP(A2,D:E,2,FALSE)"), + ("HOY()", "TODAY()"), + ("=verdadero", "TRUE"), + ("=falso", "FALSE"), + ("VERDADERO()", "TRUE()"), + ("FALSO()", "FALSE()"), + ("SUMA(1;SUM(2;3);PROMEDIO(4;6))", "SUM(1,SUM(2,3),AVERAGE(4,6))"), + ("SI(VERDADERO;;)", "IF(TRUE,,)"), + ("LAMBDA(x;x+1)(2)", "LAMBDA(x,x+1)(2)"), + ] { + assert_eq!(input(src), expected, "{src}"); + } +} + +#[test] +fn spanish_input_preserves_reference_and_array_syntax() { + for (src, expected) in [ + ("SUMA({1\\2;3\\4};5)", "SUM({1,2;3,4},5)"), + ("{VERDADERO\\FALSO;TRUE\\FALSE}", "{TRUE,FALSE;TRUE,FALSE}"), + ("SUM((A1:A3;C1:C3))", "SUM((A1:A3,C1:C3))"), + ("\"SUMA;FALSO\"&\"a\"\"b\"", "\"SUMA;FALSO\"&\"a\"\"b\""), + ("'SUMA;FALSO'!A1+SUMA!B2+SUMA!FALSO", "'SUMA;FALSO'!A1+SUMA!B2+SUMA!FALSO"), + ("SUMA[CONTAR.SI]+FALSO[SI]+CONTAR", "SUMA[CONTAR.SI]+FALSO[SI]+CONTAR"), + ] { + assert_eq!(input(src), expected, "{src}"); + } + for bad in [r"SUMA({1;2\3})", "{SUMA(A1)}", "{FALSO;}"] { + assert!(parse_input(bad, FormulaLocale::Es, |_| false).is_err(), "{bad}"); + } + assert_eq!(print(&parse("SUMA(1,2)+FALSO").unwrap()), "SUMA(1,2)+FALSO"); + assert!(parse("SUM(1;2)").is_err()); + assert!(parse("{VERDADERO}").is_err()); + // Pratt parsing can produce a deep left-hand AST without deep parser recursion. + let long_chain = "FALSO+".repeat(300) + "VERDADERO"; + assert!(parse_input(&long_chain, FormulaLocale::Es, |_| false).is_err()); + assert!(print_input(&parse(&long_chain).unwrap(), FormulaLocale::Es, |_| false).is_err()); +} + +#[test] +fn spanish_input_preserves_defined_and_local_names() { + let known = |name: &str| ["SUMA", "FALSO"].iter().any(|n| n.eq_ignore_ascii_case(name)); + let expr = parse_input("SUMA(2;3)+FALSO+SI(VERDADERO;4;5)", FormulaLocale::Es, known).unwrap(); + assert_eq!(print(&expr), "SUMA(2,3)+FALSO+IF(TRUE,4,5)"); + for (src, expected) in [ + ("LET(FALSO;7;FALSO)", "LET(FALSO,7,FALSO)"), + ("LET(falso;FALSO;SI(falso;1;2))", "LET(falso,FALSE,IF(falso,1,2))"), + ("LET(SUMA;LAMBDA(x;x+1);SUMA(2))", "LET(SUMA,LAMBDA(x,x+1),SUMA(2))"), + ("LET(falso;7;valor;FALSO;valor)", "LET(falso,7,valor,FALSO,valor)"), + ("LAMBDA(FALSO;FALSO)(7)", "LAMBDA(FALSO,FALSO)(7)"), + ("LAMBDA(SUMA;SUMA(2))(LAMBDA(x;x+1))", "LAMBDA(SUMA,SUMA(2))(LAMBDA(x,x+1))"), + ("LET(FALSO;7;FALSO)+FALSO", "LET(FALSO,7,FALSO)+FALSE"), + ("LET(FALSO;7;LAMBDA(x;FALSO+x)(1))", "LET(FALSO,7,LAMBDA(x,FALSO+x)(1))"), + ] { + assert_eq!(input(src), expected, "{src}"); + } +} + +#[test] +fn locale_controls_decimal_list_array_separators_and_display() { + let es = FormulaLocale::Es; + let en = FormulaLocale::En; + let expr = parse_input("SI(VERDADERO;SUMA(1,5;2,25);0)", es, |_| false).unwrap(); + assert_eq!(print(&expr), "IF(TRUE,SUM(1.5,2.25),0)"); + assert_eq!(print_input(&expr, es, |_| false).unwrap(), "SI(VERDADERO;SUMA(1,5;2,25);0)"); + assert_eq!(print_input(&expr, en, |_| false).unwrap(), print(&expr)); + assert!(parse_input("SUM(1;2)", en, |_| false).is_err()); + assert_eq!(print(&parse_input("SUMA(1,2)+FALSO", en, |_| false).unwrap()), "SUMA(1,2)+FALSO"); + assert!(parse_input("SUMA(1.5;2)", es, |_| false).is_err()); + let expr = parse("SUM({1.5,2.25;3,4})+SUM((A1:A3,C1:C3))").unwrap(); + let shown = print_input(&expr, es, |_| false).unwrap(); + assert_eq!(print(&parse_input(&shown, es, |_| false).unwrap()), print(&expr)); + let expr = parse("LET(SUMA,LAMBDA(x,x+1),SUM(SUMA(2),FALSE))").unwrap(); + let shown = print_input(&expr, es, |n| n == "FALSO").unwrap(); + assert_eq!(shown, "LET(SUMA;LAMBDA(x;x+1);SUM(SUMA(2);FALSE))"); + assert_eq!(print(&parse_input(&shown, es, |n| n == "FALSO").unwrap()), print(&expr)); +} diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 37ec0c8..b7e74f3 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -1208,20 +1208,21 @@ fn insert_function(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog, confir let r = ui.add(egui::TextEdit::singleline(&mut d.search).desired_width(300.0).hint_text("e.g. lookup, average, date")); r.request_focus(); }); + let locale = app.ui.language.formula_locale(); let list = gridcraft_engine::cmd::formulas::function_list(); let q = d.search.to_ascii_lowercase(); let hits: Vec<&Json> = list .iter() .filter(|f| { q.is_empty() - || f["name"].as_str().is_some_and(|n| n.to_ascii_lowercase().contains(&q)) + || f["name"].as_str().is_some_and(|n| locale.function_name(n).to_ascii_lowercase().contains(&q)) || f["description"].as_str().is_some_and(|n| n.to_ascii_lowercase().contains(&q)) }) .take(300) .collect(); egui::ScrollArea::vertical().max_height(260.0).show(ui, |ui| { for (i, f) in hits.iter().enumerate() { - let name = f["name"].as_str().unwrap_or(""); + let name = locale.function_name(f["name"].as_str().unwrap_or("")); let r = ui.add( egui::Button::selectable(i == d.list_index, format!("{name:<18} {}", f["category"].as_str().unwrap_or(""))) .min_size(vec2(480.0, 20.0)), @@ -1237,12 +1238,13 @@ fn insert_function(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog, confir }); if let Some(f) = hits.get(d.list_index) { ui.separator(); - ui.label(egui::RichText::new(f["signature"].as_str().unwrap_or("")).strong()); - ui.label(f["description"].as_str().unwrap_or("")); + ui.label(egui::RichText::new(crate::formula_locale::signature(f["name"].as_str().unwrap_or(""), locale).unwrap_or_default()).strong()); + ui.label(crate::formula_locale::description(f["name"].as_str().unwrap_or(""), locale).unwrap_or_default()); } let mut open = true; ok_cancel(ui, confirm, &mut open); if *confirm && let Some(n) = hits.get(d.list_index).and_then(|f| f["name"].as_str()) { + let n = app.session.active().map(|doc| crate::formula_locale::completion_name(n, locale, &doc.wb, doc.wb.active_sheet)).unwrap_or(n); let cur = app.editor.as_ref().map(|e| e.text.clone()); let text = match cur { Some(t) if t.starts_with('=') => format!("{t}{n}("), diff --git a/crates/ui-egui/src/editor.rs b/crates/ui-egui/src/editor.rs index aa4c64f..dd18ca3 100644 --- a/crates/ui-egui/src/editor.rs +++ b/crates/ui-egui/src/editor.rs @@ -19,6 +19,8 @@ pub const REF_COLORS: [Color32; 8] = [ #[derive(Clone, Debug)] pub struct EditState { pub sheet: usize, + /// Syntax of this edit, captured when it starts (prevents a language switch reinterpreting it). + pub locale: gridcraft_engine::formula::FormulaLocale, pub cell: CellRef, pub text: String, /// Started by typing (Enter mode: arrows commit) rather than F2 (Edit mode: arrows move the caret). @@ -46,6 +48,7 @@ impl EditState { let caret = text.chars().count(); EditState { sheet, + locale: Default::default(), cell, text, enter_mode, @@ -163,7 +166,8 @@ impl EditState { continue; } match c { - ')' => depth += 1, + ')' | '}' | ']' => depth += 1, + '{' | '[' => depth -= 1, '(' => { if depth == 0 { let name: String = chars[..i] @@ -181,7 +185,7 @@ impl EditState { } depth -= 1; } - ',' if depth == 0 => arg += 1, + c if c == self.locale.list_separator() && depth == 0 => arg += 1, _ => {} } } diff --git a/crates/ui-egui/src/formula_bar.rs b/crates/ui-egui/src/formula_bar.rs index 826c996..890ba2a 100644 --- a/crates/ui-egui/src/formula_bar.rs +++ b/crates/ui-egui/src/formula_bar.rs @@ -70,7 +70,11 @@ fn active_input(app: &SheetApp) -> String { if c.formula.is_some() && d.wb.styles.get(c.style).protection.hidden && sh.is_protected() { return String::new(); } - return c.input_text(); + return if c.formula.is_some() { + crate::formula_locale::display(&c.input_text(), app.ui.language.formula_locale(), &d.wb, d.wb.active_sheet) + } else { + c.input_text() + }; } // A spilled cell shows the anchor's formula greyed out in Excel; we show it plainly. for (anchor, r) in &sh.spill_ranges { @@ -80,7 +84,7 @@ fn active_input(app: &SheetApp) -> String { if gridcraft_engine::display::formula_hidden(&d.wb, sh, *anchor) { return String::new(); } - return format!("={}", f.text); + return crate::formula_locale::display(&format!("={}", f.text), app.ui.language.formula_locale(), &d.wb, d.wb.active_sheet); } } String::new() @@ -296,7 +300,12 @@ pub fn editor_widget(app: &mut SheetApp, ui: &mut egui::Ui, id: egui::Id, font: ed.completion = crate::editor::column_completion(sh, ed.cell, &ed.text); } ed.point = None; - let names: Vec = function_names(); + let names: Vec = function_names() + .iter() + .map(|name| { + app.session.active().map(|d| crate::formula_locale::completion_name(name, ed.locale, &d.wb, ed.sheet)).unwrap_or(name).to_string() + }) + .collect(); ed.update_autocomplete(&names); app.session.mode = if ed.can_point() { gridcraft_engine::Mode::Point @@ -337,11 +346,11 @@ pub fn editor_widget(app: &mut SheetApp, ui: &mut egui::Ui, id: egui::Id, font: if r.clicked() { pick = Some(i); } - if sel && let Some(desc) = function_description(n) { + if sel && let Some(desc) = crate::formula_locale::description(n, ed.locale) { r.on_hover_text(desc); } } - if let Some(desc) = ed.autocomplete.get(ed.ac_index).and_then(|n| function_description(n)) { + if let Some(desc) = ed.autocomplete.get(ed.ac_index).and_then(|n| crate::formula_locale::description(n, ed.locale)) { ui.separator(); ui.add(egui::Label::new(egui::RichText::new(desc).small()).wrap()); } @@ -351,20 +360,20 @@ pub fn editor_widget(app: &mut SheetApp, ui: &mut egui::Ui, id: egui::Id, font: ed.request_focus = true; } } else if let Some((name, arg)) = ed.current_function() - && let Some(sig) = function_signature(&name) + && let Some(sig) = crate::formula_locale::signature(&name, ed.locale) { ui.horizontal_wrapped(|ui| { ui.spacing_mut().item_spacing.x = 0.0; let (head, rest) = sig.split_once('(').unwrap_or((&sig, "")); - ui.label(egui::RichText::new(format!("{head}(")).font(theme::ui_font(12.0))); + ui.label(egui::RichText::new(format!("{}(", ed.locale.function_name(head))).font(theme::ui_font(12.0))); let inner = rest.trim_end_matches(')'); - let parts: Vec<&str> = inner.split(", ").collect(); + let parts: Vec<&str> = inner.split(&format!("{} ", ed.locale.list_separator())).collect(); for (i, part) in parts.iter().enumerate() { let is_cur = i == arg.min(parts.len().saturating_sub(1)) || (part.contains("...") && arg >= i); let txt = egui::RichText::new(*part).font(if is_cur { theme::ui_bold(12.0) } else { theme::ui_font(12.0) }); ui.label(txt); if i + 1 < parts.len() { - ui.label(egui::RichText::new(", ").font(theme::ui_font(12.0))); + ui.label(egui::RichText::new(format!("{} ", ed.locale.list_separator())).font(theme::ui_font(12.0))); } } ui.label(egui::RichText::new(")").font(theme::ui_font(12.0))); diff --git a/crates/ui-egui/src/formula_locale.rs b/crates/ui-egui/src/formula_locale.rs new file mode 100644 index 0000000..a6d3d1b --- /dev/null +++ b/crates/ui-egui/src/formula_locale.rs @@ -0,0 +1,100 @@ +//! UI formula syntax boundary. Engine commands, control/API callers and XLSX keep English. +use gridcraft_engine::formula::{self, FormulaLocale, ParseError}; +use gridcraft_engine::model::Workbook; + +pub(crate) fn canonical( + text: &str, + locale: FormulaLocale, + wb: &Workbook, + sheet: usize, + cell: gridcraft_engine::core::CellRef, +) -> Result { + if locale == FormulaLocale::En || wb.sheet(sheet).is_some_and(|sh| wb.styles.get(sh.style_id(cell)).num_fmt.as_str() == "@") { + return Ok(text.to_string()); + } + let body = text.trim(); + if body.starts_with('=') + || ((body.starts_with('+') || body.starts_with('-')) && body.chars().nth(1).is_some_and(|c| c.is_ascii_alphabetic() || c == '(')) + { + let known = |name: &str| wb.name(name, sheet).is_some() || wb.table(name).is_some(); + // Match the engine's existing convenience for missing closing parentheses. + let mut body = body.to_string(); + let mut expr = formula::parse_input(&body, locale, known); + for _ in 0..8 { + if expr.is_ok() { + break; + } + body.push(')'); + expr = formula::parse_input(&body, locale, known); + } + return Ok(format!("={}", formula::print(&expr?))); + } + // Decimal-comma number entry follows the same preference, without rewriting text or dates. + let normalized = body.replace(locale.decimal_separator(), "."); + let number = normalized.strip_suffix('%').unwrap_or(&normalized); + if number.parse::().is_ok_and(f64::is_finite) { + return Ok(normalized); + } + Ok(text.to_string()) +} + +pub(crate) fn display(text: &str, locale: FormulaLocale, wb: &Workbook, sheet: usize) -> String { + let Some(body) = text.strip_prefix('=') else { return text.to_string() }; + formula::parse(body) + .and_then(|expr| formula::print_input(&expr, locale, |name| wb.name(name, sheet).is_some() || wb.table(name).is_some())) + .map(|body| format!("={body}")) + .unwrap_or_else(|_| text.to_string()) +} + +// Descriptions/signatures for the supported Spanish function table; untranslated functions +// retain their English help. Parameter punctuation follows the same locale as entry. +const SPANISH_HELP: &[(&str, &str, &str)] = &[ + ("SUM", "SUMA(número1; [número2]; ...)", "Suma los números."), + ("AVERAGE", "PROMEDIO(número1; [número2]; ...)", "Devuelve el promedio de los números."), + ("IF", "SI(prueba_lógica; valor_si_verdadero; [valor_si_falso])", "Devuelve un valor según una condición."), + ("COUNT", "CONTAR(valor1; [valor2]; ...)", "Cuenta las celdas que contienen números."), + ("SUMIF", "SUMAR.SI(rango; criterio; [rango_suma])", "Suma las celdas que cumplen un criterio."), + ("COUNTIF", "CONTAR.SI(rango; criterio)", "Cuenta las celdas que cumplen un criterio."), + ("VLOOKUP", "BUSCARV(valor_buscado; matriz; indicador_columnas; [ordenado])", "Busca un valor en la primera columna de una tabla."), + ("TODAY", "HOY()", "Devuelve la fecha actual."), + ("TRUE", "VERDADERO()", "Devuelve el valor lógico VERDADERO."), + ("FALSE", "FALSO()", "Devuelve el valor lógico FALSO."), +]; + +pub(crate) fn signature(name: &str, locale: FormulaLocale) -> Option { + let canonical = locale.canonical_name(name); + if locale == FormulaLocale::Es + && let Some((_, signature, _)) = SPANISH_HELP.iter().find(|(name, ..)| *name == canonical) + { + return Some((*signature).into()); + } + crate::formula_bar::function_signature(canonical).map(|s| s.replace(',', &locale.list_separator().to_string())) +} + +pub(crate) fn description(name: &str, locale: FormulaLocale) -> Option { + let canonical = locale.canonical_name(name); + if locale == FormulaLocale::Es + && let Some((_, _, description)) = SPANISH_HELP.iter().find(|(name, ..)| *name == canonical) + { + return Some((*description).into()); + } + crate::formula_bar::function_description(canonical) +} + +/// Avoid suggesting an alias that would call a workbook/table name instead of the function. +pub(crate) fn completion_name<'a>(name: &'a str, locale: FormulaLocale, wb: &Workbook, sheet: usize) -> &'a str { + let alias = locale.function_name(name); + if wb.name(alias, sheet).is_some() || wb.table(alias).is_some() { name } else { alias } +} + +/// Engine-generated edit templates use canonical syntax. The only incomplete template is +/// the bare function opening emitted by Insert Function; typed user input never enters here. +pub(crate) fn display_template(text: &str, locale: FormulaLocale, wb: &Workbook, sheet: usize) -> String { + if let Some(name) = text.strip_prefix('=').and_then(|s| s.strip_suffix('(')) + && !name.is_empty() + && name.chars().all(|c| c.is_alphanumeric() || matches!(c, '_' | '.')) + { + return format!("={}(", completion_name(name, locale, wb, sheet)); + } + display(text, locale, wb, sheet) +} diff --git a/crates/ui-egui/src/grid.rs b/crates/ui-egui/src/grid.rs index 4bd50dd..2a312ba 100644 --- a/crates/ui-egui/src/grid.rs +++ b/crates/ui-egui/src/grid.rs @@ -604,7 +604,10 @@ fn paint_quadrant(p: &Painter, geo: &Geo, wb: &Workbook, si: usize, sh: &Sheet, // Digits fit by the cell's own font and size, not the default 11pt (9pt marks in narrow columns). let char_w = p.layout_no_wrap("0".to_string(), font.clone(), Color32::PLACEHOLDER).size().x.max(1.0); let avail_w = rect.right() - text_left; - let (text, ncolor, numeric, fill_char) = display_text(wb, sh, c, &v, st, avail_w / z.max(0.1) * z, char_w); + let (mut text, ncolor, numeric, fill_char) = display_text(wb, sh, c, &v, st, avail_w / z.max(0.1) * z, char_w); + if sh.show_formulas && sh.cell(c).is_some_and(|cell| cell.formula.is_some()) { + text = crate::formula_locale::display(&text, crate::i18n::current(p.ctx()).formula_locale(), wb, si); + } if text.is_empty() { continue; } diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs index 296bf38..1e7b212 100644 --- a/crates/ui-egui/src/i18n.rs +++ b/crates/ui-egui/src/i18n.rs @@ -19,6 +19,7 @@ mod zh; pub enum Language { #[default] En, + Es, Zh, Ja, Ko, @@ -28,12 +29,13 @@ pub enum Language { } impl Language { - pub const ALL: [Self; 6] = [Self::En, Self::Zh, Self::Ja, Self::Ko, Self::Ru, Self::PtBr]; + pub const ALL: [Self; 7] = [Self::En, Self::Es, Self::Zh, Self::Ja, Self::Ko, Self::Ru, Self::PtBr]; /// The language's own name, shown in the switcher. pub fn name(self) -> &'static str { match self { Self::En => "English", + Self::Es => "Español", Self::Zh => "简体中文", Self::Ja => "日本語", Self::Ko => "한국어", @@ -46,6 +48,7 @@ impl Language { pub const fn code(self) -> &'static str { match self { Self::En => "en", + Self::Es => "es", Self::Zh => "zh", Self::Ja => "ja", Self::Ko => "ko", @@ -61,6 +64,7 @@ impl Language { fn from_tag(tag: &str) -> Option { match tag.trim().split(['-', '_']).next()?.to_ascii_lowercase().as_str() { "en" => Some(Self::En), + "es" => Some(Self::Es), "zh" => Some(Self::Zh), "ja" => Some(Self::Ja), "ko" => Some(Self::Ko), @@ -83,13 +87,17 @@ impl Language { } } + pub fn formula_locale(self) -> gridcraft_engine::formula::FormulaLocale { + if self == Self::Es { gridcraft_engine::formula::FormulaLocale::Es } else { gridcraft_engine::formula::FormulaLocale::En } + } + /// Translates an interface label; untranslated labels fall back to English. /// /// A label that needs two translations (Home's text "Orientation" vs Page Layout's print /// "Orientation") is keyed `context|label`: English shows only the part after the `|`. pub fn tr(self, text: &str) -> &str { let translated = match self { - Self::En => None, + Self::En | Self::Es => None, Self::Ja => JAPANESE.iter().find(|(english, _)| *english == text).map(|(_, translated)| *translated), Self::Zh => zh::translate(text), Self::Ko => ko::translate(text), @@ -464,6 +472,7 @@ mod tests { fn tags_reduce_to_a_supported_language() { assert_eq!(Language::from_tag("ja-JP"), Some(Language::Ja)); assert_eq!(Language::from_tag("en_US"), Some(Language::En)); + assert_eq!(Language::from_tag("es_MX"), Some(Language::Es)); assert_eq!(Language::from_tag("zh-Hans-CN"), Some(Language::Zh)); assert_eq!(Language::from_tag("ko-KR"), Some(Language::Ko)); assert_eq!(Language::from_tag("RU_ru"), Some(Language::Ru)); diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index aa5f1db..6d29f59 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -13,6 +13,7 @@ pub mod credits; pub mod dialogs; pub mod editor; pub mod formula_bar; +mod formula_locale; pub mod grid; pub mod i18n; pub mod icons; @@ -303,7 +304,7 @@ impl SheetApp { self.ui.language = l; Ok(json!({"language": l})) } - None => Err(format!("unknown language {code:?} (use \"en\", \"zh\", \"ja\", \"ko\", \"ru\" or \"pt\")")), + None => Err(format!("unknown language {code:?} (use \"en\", \"es\", \"zh\", \"ja\", \"ko\", \"ru\" or \"pt\")")), } } "app.language.english" => { @@ -334,7 +335,15 @@ impl SheetApp { match req { UiRequest::Dialog(name, params) => self.open_dialog(&name, params), UiRequest::Message(m) => self.message = Some(("GridCraft".into(), m)), - UiRequest::EditCell(text) => self.begin_edit(text, false), + UiRequest::EditCell(text) => { + let text = text.map(|text| { + self.session + .active() + .map(|d| formula_locale::display_template(&text, self.ui.language.formula_locale(), &d.wb, d.wb.active_sheet)) + .unwrap_or(text) + }); + self.begin_edit(text, false); + } UiRequest::OpenUrl(u) => { if let Some(f) = &self.services.open_url { f(&u); @@ -412,12 +421,17 @@ impl SheetApp { let Some(d) = self.session.active() else { return }; let Some(sh) = d.wb.active() else { return }; let at = sh.merge_at(d.selection.active).map(|m| m.start).unwrap_or(d.selection.active); - let current = sh.cell(at).map(|c| c.input_text()).unwrap_or_default(); + let locale = self.ui.language.formula_locale(); + let current = sh + .cell(at) + .map(|c| if c.formula.is_some() { formula_locale::display(&c.input_text(), locale, &d.wb, d.wb.active_sheet) } else { c.input_text() }) + .unwrap_or_default(); let (text, replace) = match text { Some(t) => (t, true), None => (current.clone(), false), }; let mut ed = editor::EditState::new(d.wb.active_sheet, at, text, replace, from_formula_bar); + ed.locale = locale; if replace && !ed.is_formula() { ed.completion = editor::column_completion(sh, at, &ed.text); } @@ -433,6 +447,16 @@ impl SheetApp { Some(full) if full.to_lowercase().starts_with(&ed.text.to_lowercase()) => full.clone(), _ => ed.text.clone(), }; + let text = match self.session.active().map(|d| formula_locale::canonical(&text, ed.locale, &d.wb, ed.sheet, ed.cell)) { + Some(Ok(text)) => text, + Some(Err(error)) => { + self.message = Some(("GridCraft".into(), error.to_string())); + self.editor = Some(ed); + self.session.mode = gridcraft_engine::Mode::Edit; + return false; + } + None => text, + }; // Data validation. if let Some(d) = self.session.active() && let Some((dv, msg)) = gridcraft_engine::cmd::data::check_validation(&d.wb, ed.sheet, ed.cell, &text) diff --git a/crates/ui-egui/src/ribbon.rs b/crates/ui-egui/src/ribbon.rs index 28d025c..7b0e392 100644 --- a/crates/ui-egui/src/ribbon.rs +++ b/crates/ui-egui/src/ribbon.rs @@ -1411,9 +1411,12 @@ fn formulas(app: &mut SheetApp, ui: &mut Ui) { if f["category"].as_str() == Some(cat) && let Some(n) = f["name"].as_str() { - let desc = f["description"].as_str().unwrap_or("").to_string(); - if ui.button(n).on_hover_text(desc).clicked() { - app.begin_edit(Some(format!("={n}(")), false); + let locale = app.ui.language.formula_locale(); + let name = + app.session.active().map(|d| crate::formula_locale::completion_name(n, locale, &d.wb, d.wb.active_sheet)).unwrap_or(n); + let desc = crate::formula_locale::description(n, locale).unwrap_or_default(); + if ui.button(name).on_hover_text(desc).clicked() { + app.run_or_alert("formulas.insertFunction", json!({"name": n})); ui.close(); } } diff --git a/crates/ui-egui/tests/formula_locale.rs b/crates/ui-egui/tests/formula_locale.rs new file mode 100644 index 0000000..a92ab13 --- /dev/null +++ b/crates/ui-egui/tests/formula_locale.rs @@ -0,0 +1,187 @@ +//! Language selection must affect UI entry, never the canonical engine/file boundary. +use gridcraft_engine::{ + Session, + core::{CellRef, Value}, +}; +use gridcraft_ui_egui::{SheetApp, UiState, i18n::Language}; +use serde_json::json; + +fn app() -> SheetApp { + let mut session = Session::new(); + session.new_workbook(); + let mut app = SheetApp::new(session, Default::default()); + app.ui.language = Language::En; + app +} +fn formula(app: &SheetApp) -> String { + app.session.active().unwrap().wb.active().unwrap().cell(CellRef::new(0, 0)).unwrap().formula.as_ref().unwrap().text.to_string() +} +fn enter(app: &mut SheetApp, text: &str) -> bool { + app.begin_edit(Some(text.into()), false); + app.commit_edit(0, 0, false, false) +} + +#[test] +fn language_controls_entry_display_persistence_and_xlsx_roundtrip() { + let mut app = app(); + assert!(!enter(&mut app, "=SUM(1;2)")); + app.run("app.language.set", json!({"language":"es"})).unwrap(); + assert!(enter(&mut app, "=SI(VERDADERO;SUMA(1,5;2,25);0)")); + assert_eq!(formula(&app), "IF(TRUE,SUM(1.5,2.25),0)"); + let saved = app.run("file.save", json!({"format":"xlsx"})).unwrap(); + app.run("file.open", json!({"name":"locale.xlsx", "base64":saved["base64"]})).unwrap(); + app.begin_edit(None, true); + assert_eq!(app.editor.as_ref().unwrap().text, "=SI(VERDADERO;SUMA(1,5;2,25);0)"); + assert!(app.commit_edit(0, 0, false, false)); + assert_eq!(formula(&app), "IF(TRUE,SUM(1.5,2.25),0)"); + let state: UiState = serde_json::from_str(&serde_json::to_string(&app.ui).unwrap()).unwrap(); + assert_eq!(state.language, Language::Es); + app.run("app.language.set", json!({"language":"en"})).unwrap(); + app.begin_edit(None, false); + assert_eq!(app.editor.as_ref().unwrap().text, "=IF(TRUE,SUM(1.5,2.25),0)"); +} + +#[test] +fn language_does_not_change_api_syntax_or_an_edit_already_in_progress() { + let mut app = app(); + app.run("app.language.set", json!({"language":"es"})).unwrap(); + assert!(app.run("cell.set", json!({"cell":"A1","input":"=SUM(1,2)"})).is_ok()); + assert!(app.run("cell.set", json!({"cell":"A2","input":"=SUMA(1;2)"})).is_err()); + app.begin_edit(Some("=SUMA(1,5;2)".into()), false); + app.run("app.language.set", json!({"language":"en"})).unwrap(); + assert!(app.commit_edit(0, 0, false, false)); + assert_eq!(formula(&app), "SUM(1.5,2)"); +} + +#[test] +fn defined_and_local_names_survive_localized_editing() { + let mut app = app(); + app.run("formulas.defineName", json!({"name":"FALSO", "refersTo":"=7"})).unwrap(); + app.run("app.language.set", json!({"language":"es"})).unwrap(); + assert!(enter(&mut app, "=LET(SUMA;LAMBDA(x;x+1);SUMA(2))+FALSO")); + let original = formula(&app); + app.begin_edit(None, false); + assert_eq!(app.editor.as_ref().unwrap().text, "=LET(SUMA;LAMBDA(x;x+1);SUMA(2))+FALSO"); + assert!(app.commit_edit(0, 0, false, false)); + assert_eq!(formula(&app), original); + assert_eq!(app.session.active().unwrap().wb.active().unwrap().value(CellRef::new(0, 0)), Value::Number(10.0)); +} + +#[test] +fn localized_argument_hints_ignore_decimal_commas_arrays_and_nested_calls() { + let mut app = app(); + app.run("app.language.set", json!({"language":"es"})).unwrap(); + for (text, expected) in [("=SI(1,5;SUMA(2;3);", 2), ("=SUMA({1,5\\2;3\\4};", 1), ("=SUMA(Table1[[a],[b]];", 1)] { + app.begin_edit(Some(text.into()), false); + assert_eq!(app.editor.as_ref().unwrap().current_function().unwrap().1, expected); + } +} + +#[test] +fn localized_autocomplete_is_rendered_and_inserts_the_selected_name() { + let mut app = app(); + app.run("app.language.set", json!({"language":"es"})).unwrap(); + let mut h = egui_kittest::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); + }, + app, + ); + h.run_steps(4); + h.state_mut().begin_edit(Some("=SU".into()), true); + h.run_steps(4); + h.input_mut().events.push(egui::Event::Text("M".into())); + h.run_steps(4); + assert!(h.state().editor.as_ref().unwrap().autocomplete.iter().any(|n| n == "SUMA")); + assert!(!h.state().editor.as_ref().unwrap().autocomplete.iter().any(|n| n == "SUM")); + if let Ok(path) = std::env::var("GRIDCRAFT_LOCALE_SCREENSHOT") { + h.render().unwrap().save(path).unwrap(); + } + let ed = h.state_mut().editor.as_mut().unwrap(); + ed.ac_index = ed.autocomplete.iter().position(|n| n == "SUMA").unwrap(); + assert!(ed.accept_autocomplete()); + assert_eq!(ed.text, "=SUMA("); + h.run_steps(4); + h.input_mut().events.push(egui::Event::Text("1,5;".into())); + h.run_steps(4); + assert_eq!(h.state().editor.as_ref().unwrap().current_function(), Some(("SUMA".into(), 1))); + if let Ok(path) = std::env::var("GRIDCRAFT_LOCALE_SCREENSHOT") { + h.render().unwrap().save(std::path::Path::new(&path).with_file_name("spanish-hints.png")).unwrap(); + h.state_mut().cancel_edit(); + h.state_mut().ui.ribbon_tab = "View".into(); + h.run_steps(4); + h.render().unwrap().save(std::path::Path::new(&path).with_file_name("spanish-setting.png")).unwrap(); + } + h.state_mut().cancel_edit(); + h.state_mut().run("app.language.set", json!({"language":"en"})).unwrap(); + h.state_mut().begin_edit(Some("=SU".into()), true); + h.run_steps(4); + h.input_mut().events.push(egui::Event::Text("M".into())); + h.run_steps(4); + assert!(h.state().editor.as_ref().unwrap().autocomplete.iter().any(|n| n == "SUM")); + assert!(!h.state().editor.as_ref().unwrap().autocomplete.iter().any(|n| n == "SUMA")); +} + +#[test] +fn locale_preserves_text_cells_and_existing_entry_conveniences() { + let mut app = app(); + app.run("app.language.set", json!({"language":"es"})).unwrap(); + assert!(enter(&mut app, "=SUMA(1,5;2")); + assert_eq!(formula(&app), "SUM(1.5,2)"); + assert!(enter(&mut app, "-1,5")); + assert_eq!(app.session.active().unwrap().wb.active().unwrap().value(CellRef::new(0, 0)), Value::Number(-1.5)); + app.run("home.numberFormat", json!({"format":"@"})).unwrap(); + assert!(enter(&mut app, "=SUM(1,2)")); + assert_eq!(app.session.active().unwrap().wb.active().unwrap().value(CellRef::new(0, 0)), Value::text("=SUM(1,2)")); + app.begin_edit(None, true); + assert_eq!(app.editor.as_ref().unwrap().text, "=SUM(1,2)"); +} + +#[test] +fn canonical_engine_templates_enter_the_selected_formula_language() { + let mut app = app(); + app.run("app.language.set", json!({"language":"es"})).unwrap(); + app.run("formulas.insertFunction", json!({"name":"SUM"})).unwrap(); + assert_eq!(app.editor.as_ref().unwrap().text, "=SUMA("); + app.cancel_edit(); + app.run("cell.set", json!({"cell":"A1","input":"2"})).unwrap(); + app.run("selection.set", json!({"range":"A2"})).unwrap(); + app.run("formulas.autoSum", json!({"enter":false})).unwrap(); + assert!(app.editor.as_ref().unwrap().text.starts_with("=SUMA(")); + app.cancel_edit(); + // Text supplied by the user is already in the selected UI syntax; do not translate it twice. + app.begin_edit(Some("=SUMA(1,5;2)".into()), false); + assert_eq!(app.editor.as_ref().unwrap().text, "=SUMA(1,5;2)"); + assert!(app.commit_edit(0, 0, false, false)); + if let Ok(path) = std::env::var("GRIDCRAFT_LOCALE_SCREENSHOT") { + app.ui.ribbon_tab = "Formulas".into(); + app.run("formulas.insertFunction", json!({"name":"SUM"})).unwrap(); + let mut h = egui_kittest::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); + }, + app, + ); + h.run_steps(5); + h.render().unwrap().save(std::path::Path::new(&path).with_file_name("spanish-ribbon-template.png")).unwrap(); + } +} + +#[test] +fn portuguese_interface_retains_its_canonical_formula_fallback() { + let mut app = app(); + app.run("app.language.set", json!({"code":"pt-BR"})).unwrap(); + assert_eq!(app.ui.language, Language::PtBr); + assert_eq!(app.ui.language.tr("Data"), "Dados"); + assert!(enter(&mut app, "=SUM(1.5,2.25)")); + app.begin_edit(None, true); + assert_eq!(app.editor.as_ref().unwrap().text, "=SUM(1.5,2.25)"); + assert!(!enter(&mut app, "=SUMA(1;2)")); + let restored: UiState = serde_json::from_str(&serde_json::to_string(&app.ui).unwrap()).unwrap(); + assert_eq!(restored.language, Language::PtBr); + assert_eq!(Language::Es.tr("Page Layout|Orientation"), "Orientation"); +} diff --git a/docs/decisions.md b/docs/decisions.md new file mode 100644 index 0000000..ee3a647 --- /dev/null +++ b/docs/decisions.md @@ -0,0 +1,12 @@ +# Decisions + +## Formula localization uses the existing interface preference + +PR #98 builds on the persisted `UiState.language` introduced by #31 and extended by #104. +Español selects the Spanish function table and list/decimal separators; there is no competing +locale setting. Formula syntax without a supplied translation table falls back to English. +The editor translates to canonical English before engine commands, and translates back only +for display. API, clipboard and XLSX syntax remain stable. Existing names override aliases. + +The revision is based directly on main after #31/#104 and Portuguese interface support (#82) +merged. Those locales retain their existing formula fallback; this change adds only Spanish. diff --git a/docs/formula-input.md b/docs/formula-input.md new file mode 100644 index 0000000..e07132c --- /dev/null +++ b/docs/formula-input.md @@ -0,0 +1,38 @@ +# Formula language + +View → Interface language is the existing, persisted language preference. Choose **Español** +for Spanish formula entry and display. On first run the preference follows the system language. +English and the other interface languages currently use the English formula table and separators. + +| Spanish | Canonical English | +| --- | --- | +| `SUMA` | `SUM` | +| `PROMEDIO` | `AVERAGE` | +| `SI` | `IF` | +| `CONTAR` | `COUNT` | +| `SUMAR.SI` | `SUMIF` | +| `CONTAR.SI` | `COUNTIF` | +| `BUSCARV` | `VLOOKUP` | +| `HOY` | `TODAY` | +| `VERDADERO`, `FALSO` | `TRUE`, `FALSE` (literals or function calls) | + +Names are case-insensitive. Spanish formulas use decimal commas (`1,5`), semicolon arguments +and reference unions, and backslashes between array columns (`{1\2;3\4}`). For example, +`=SI(VERDADERO;SUMA(1,5;2,25);0)` evaluates to 3.75. English mode retains comma arguments, +decimal points and comma array columns; it does not silently translate Spanish aliases. + +The cell editor, formula bar, Show Formulas, autocomplete, the Formula ribbon, Insert Function, and argument hints +use the selected formula language. Common Spanish function help is translated; functions +without a Spanish entry retain their English names/help. General interface labels and errors +fall back to English where Spanish translations are not provided. An edit captures its language +when it starts so switching the preference cannot reinterpret unfinished input. + +Workbook/sheet names, table names, LET bindings and LAMBDA parameters take precedence over +aliases. Text, sheet names and table column names are unchanged. When a localized spelling +would collide with a name, displayed formulas retain the canonical spelling to preserve meaning. + +Stored formulas, XLSX import/export, engine commands, control/API calls, CLI evaluation and +clipboard exchange retain strict English syntax. This also applies to advanced dialogs such as +Name Manager and conditional-format rules. Localization happens at the cell/formula-bar editor +boundary, including filling the selected range with Ctrl+Enter; it does not change the workbook +format or teach the calculation engine ambiguous aliases.