Fix #45: follow the selected language for formula editing

This commit is contained in:
Harrison O'Connor-Hoover 2026-10-10 13:48:20 -04:00
parent e72e16a0ec
commit ada33d1ad5
17 changed files with 746 additions and 36 deletions

View File

@ -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

204
crates/formula/src/input.rs Normal file
View File

@ -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<Expr, ParseError> {
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<String, ParseError> {
// 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<bool> {
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<String>, 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(())
}

View File

@ -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<Vec<Token>, 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<Vec<Token>, 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<Vec<Token>, 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<Vec<Token>, 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<Vec<Token>, 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<Vec<Token>, 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<Vec<Token>, 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<Vec<Token>, 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();

View File

@ -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};

View File

@ -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<Expr, ParseError> {
parse_syntax(src, crate::FormulaLocale::En)
}
pub(crate) fn parse_syntax(src: &str, locale: crate::FormulaLocale) -> Result<Expr, ParseError> {
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<Token>,
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)))),
};

View File

@ -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));
}

View File

@ -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}("),

View File

@ -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,
_ => {}
}
}

View File

@ -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<String> = function_names();
let names: Vec<String> = 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)));

View File

@ -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<String, ParseError> {
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::<f64>().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<String> {
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<String> {
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)
}

View File

@ -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;
}

View File

@ -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<Self> {
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));

View File

@ -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)

View File

@ -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();
}
}

View File

@ -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");
}

12
docs/decisions.md Normal file
View File

@ -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.

38
docs/formula-input.md Normal file
View File

@ -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.