Merge pull request #98 from harrisonoconnorhover/codex/spanish-formulas
Support common Spanish formula names and semicolon arguments
This commit is contained in:
commit
3e40014eb0
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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(())
|
||||
}
|
||||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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};
|
||||
|
||||
|
|
|
|||
|
|
@ -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)))),
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
|
|
@ -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}("),
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)));
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
|
|
@ -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.
|
||||
|
|
@ -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.
|
||||
Loading…
Reference in New Issue