UI: function help follows the interface language

The argument hint, autocomplete, Insert Function and the Formulas-tab menus
read per-language tables of function descriptions and argument names
(i18n/<lang>_functions.rs), keyed by the catalog's English text and
binary-searched. The hint is assembled from the formula syntax's function
name and separator plus the language's argument names, all of them or none,
so it never mixes two languages. Insert Function searches the translated
descriptions and shows readable category names. SPANISH_HELP moves to
es_functions.rs with its 10 functions.
This commit is contained in:
quantmind-br
2026-10-15 10:49:13 -03:00
parent e6b6b34970
commit 47268a42af
8 changed files with 202 additions and 51 deletions
+1
View File
@@ -132,6 +132,7 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in
| Date | What landed |
|---|---|
| 2026-10-15 | Function help follows the interface language: argument hints, autocomplete, Insert Function (search and readable categories) and the Formulas-tab menus read per-language tables of descriptions and argument names; Spanish keeps its 10 functions in the new format |
| 2026-10-12 | Rows auto-fit oversized fonts: opening a file and whole-row/column formatting grow non-custom rows to their tallest font (#833) |
| 2026-10-12 | Number formats: parsing a format code is linear in its length (long runs of `[`, `,` or `/` no longer take quadratic time), and an unclosed `[` is a literal when splitting sections as well as when tokenizing (#810) |
| 2026-10-12 | Number formats: a fraction denominator with 65,536 zeros no longer crashes; format codes are capped at 255 characters like Excel (longer codes display as General, `TEXT` gives `#VALUE!`) (#807) |
+25 -12
View File
@@ -1746,30 +1746,30 @@ fn format_cells(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog, confirm:
}
fn insert_function(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog, confirm: &mut bool) {
let language = app.ui.language;
ui.horizontal(|ui| {
ui.label("Search:");
let r = ui.add(egui::TextEdit::singleline(&mut d.search).desired_width(300.0).hint_text("e.g. lookup, average, date"));
ui.label(language.tr("Search:"));
let r = ui.add(egui::TextEdit::singleline(&mut d.search).desired_width(300.0).hint_text(language.tr("e.g. lookup, average, date")));
r.request_focus();
});
let locale = app.ui.language.formula_locale();
let locale = language.formula_locale();
let list = gridcraft_engine::cmd::formulas::function_list();
let q = d.search.to_ascii_lowercase();
// The names and descriptions searched are the ones shown, so in the interface language.
let q = d.search.to_lowercase();
let hits: Vec<&Json> = list
.iter()
.filter(|f| {
q.is_empty()
|| 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))
|| f["name"].as_str().is_some_and(|n| locale.function_name(n).to_lowercase().contains(&q))
|| f["description"].as_str().is_some_and(|n| language.function_description(n).unwrap_or(n).to_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 = 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)),
);
let category = language.tr(function_category_label(f["category"].as_str().unwrap_or("")));
let r = ui.add(egui::Button::selectable(i == d.list_index, format!("{name:<18} {category}")).min_size(vec2(480.0, 20.0)));
if r.clicked() {
d.list_index = i;
}
@@ -1780,9 +1780,10 @@ fn insert_function(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog, confir
}
});
if let Some(f) = hits.get(d.list_index) {
let name = f["name"].as_str().unwrap_or("");
ui.separator();
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());
ui.label(egui::RichText::new(crate::formula_locale::signature(name, locale, language).unwrap_or_default()).strong());
ui.label(crate::formula_locale::description(name, locale, language).unwrap_or_default());
}
let mut open = true;
ok_cancel(ui, confirm, &mut open);
@@ -1800,6 +1801,18 @@ fn insert_function(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog, confir
}
}
/// Excel's name for a function category, given the catalog's id (`MathTrig`).
fn function_category_label(id: &str) -> &str {
match id {
"DateTime" => "Date & Time",
"Lookup" => "Lookup & Reference",
"MathTrig" => "Math & Trig",
"AddIn" => "Add-in and Automation",
// Financial, Logical, Text, Statistical, Engineering, Database, Information, …
other => other,
}
}
fn find(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog) {
ui.horizontal(|ui| {
for tab in ["Find", "Replace"] {
+6 -5
View File
@@ -351,6 +351,7 @@ pub fn editor_widget(app: &mut SheetApp, ui: &mut egui::Ui, id: egui::Id, font:
// Autocomplete list and argument hint under the editor.
if mine && ui.is_rect_visible(resp.rect) && (!ed.autocomplete.is_empty() || ed.current_function().is_some()) {
let below = resp.rect.left_bottom() + vec2(0.0, 4.0);
let language = app.ui.language;
// Areas have their own clip rect: keep cell helpers inside the worksheet.
let bounds = if ed.from_formula_bar { ui.ctx().content_rect() } else { ui.clip_rect() };
egui::Area::new(id.with("ac")).fixed_pos(below).constrain_to(bounds).order(egui::Order::Tooltip).show(ui.ctx(), |ui| {
@@ -366,11 +367,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) = crate::formula_locale::description(n, ed.locale) {
if sel && let Some(desc) = crate::formula_locale::description(n, ed.locale, language) {
r.on_hover_text(desc);
}
}
if let Some(desc) = ed.autocomplete.get(ed.ac_index).and_then(|n| crate::formula_locale::description(n, ed.locale)) {
if let Some(desc) = ed.autocomplete.get(ed.ac_index).and_then(|n| crate::formula_locale::description(n, ed.locale, language)) {
ui.separator();
ui.add(egui::Label::new(egui::RichText::new(desc).small()).wrap());
}
@@ -380,12 +381,12 @@ 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) = crate::formula_locale::signature(&name, ed.locale)
&& let Some(sig) = crate::formula_locale::signature(&name, ed.locale, language)
{
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!("{}(", ed.locale.function_name(head))).font(theme::ui_font(12.0)));
ui.label(egui::RichText::new(format!("{head}(")).font(theme::ui_font(12.0)));
let inner = rest.trim_end_matches(')');
let parts: Vec<&str> = inner.split(&format!("{} ", ed.locale.list_separator())).collect();
for (i, part) in parts.iter().enumerate() {
@@ -400,7 +401,7 @@ pub fn editor_widget(app: &mut SheetApp, ui: &mut egui::Ui, id: egui::Id, font:
});
// The description too, so values an argument takes (DATEDIF's units, #363) show
// while it's typed; wrapped to the hint's width.
if let Some(desc) = crate::formula_locale::description(&name, ed.locale) {
if let Some(desc) = crate::formula_locale::description(&name, ed.locale, language) {
ui.set_max_width(ui.min_rect().width().max(220.0));
ui.separator();
ui.add(egui::Label::new(egui::RichText::new(desc).small()).wrap());
+75 -30
View File
@@ -1,4 +1,5 @@
//! UI formula syntax boundary. Engine commands, control/API callers and XLSX keep English.
use crate::i18n::Language;
use gridcraft_engine::formula::{self, FormulaLocale, ParseError};
use gridcraft_engine::model::Workbook;
@@ -86,39 +87,55 @@ fn hide_udf_prefix(text: &str, locale: FormulaLocale) -> String {
out
}
// 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()))
/// A function's argument hint. The name and separators follow the formula syntax; the argument
/// names follow the interface language, all of them or none, so a hint never mixes two languages.
pub(crate) fn signature(name: &str, locale: FormulaLocale, language: Language) -> Option<String> {
let english = crate::formula_bar::function_signature(locale.canonical_name(name))?;
let Some((head, args)) = split_signature(&english) else { return Some(english) };
let separator = format!("{} ", locale.list_separator());
let args = match args.iter().map(|arg| translate_argument(arg, language)).collect::<Option<Vec<_>>>() {
Some(translated) => translated.join(&separator),
None => args.join(&separator),
};
Some(format!("{}({args})", locale.function_name(head)))
}
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());
/// A function's description in the interface language (English while untranslated).
pub(crate) fn description(name: &str, locale: FormulaLocale, language: Language) -> Option<String> {
let english = crate::formula_bar::function_description(locale.canonical_name(name))?;
Some(language.function_description(&english).map_or(english, str::to_string))
}
/// `SUM(number1, [number2], ...)` -> `SUM` and its arguments as written.
fn split_signature(signature: &str) -> Option<(&str, Vec<&str>)> {
let (head, rest) = signature.split_once('(')?;
let inner = rest.strip_suffix(')')?;
Some((head, if inner.is_empty() { Vec::new() } else { inner.split(", ").collect() }))
}
/// The catalog names of a signature's arguments (`number` for `[number2]`), without `...`; the
/// function help tables are checked against them.
#[cfg(test)]
pub(crate) fn argument_names(signature: &str) -> Vec<&str> {
split_signature(signature).map(|(_, args)| args.into_iter().map(argument_name).filter(|name| *name != "...").collect()).unwrap_or_default()
}
/// `[number2]` -> `number`. A bracket can open or close on its own (SWITCH's
/// `[default_or_value2, result2]`, LAMBDA's `[parameter1, ...]`).
fn argument_name(arg: &str) -> &str {
let arg = arg.strip_prefix('[').unwrap_or(arg);
let arg = arg.strip_suffix(']').unwrap_or(arg);
arg.trim_end_matches(|c: char| c.is_ascii_digit())
}
/// `[number2]` -> `[número2]`: brackets, index and `...` stay.
fn translate_argument(arg: &str, language: Language) -> Option<String> {
let name = argument_name(arg);
if name == "..." {
return Some(arg.to_string());
}
crate::formula_bar::function_description(canonical)
// `arg` is `[`? + `name` + index + `]`?, so the first match is the name itself.
Some(arg.replacen(name, language.function_argument(name)?, 1))
}
/// Avoid suggesting an alias that would call a workbook/table name instead of the function.
@@ -138,3 +155,31 @@ pub(crate) fn display_template(text: &str, locale: FormulaLocale, wb: &Workbook,
}
display(text, locale, wb, sheet)
}
#[cfg(test)]
mod tests {
use super::*;
fn sig(name: &str, locale: FormulaLocale, language: Language) -> String {
signature(name, locale, language).unwrap_or_default()
}
#[test]
fn function_help_follows_the_interface_language_and_the_formula_syntax() {
use FormulaLocale::{En, Es};
// Spanish help over English formulas: the name and `,` stay as typed; brackets, indexes
// and `...` survive.
assert_eq!(sig("IF", En, Language::Es), "IF(prueba_lógica, [valor_si_verdadero], [valor_si_falso])");
assert_eq!(sig("SUM", En, Language::Es), "SUM(número1, [número2], ...)");
assert_eq!(description("IF", En, Language::Es).as_deref(), Some("Devuelve un valor según una condición."));
// Spanish names and separators, from a localized name.
assert_eq!(sig("suma", Es, Language::Es), "SUMA(número1; [número2]; ...)");
assert_eq!(description("SUMA", Es, Language::Es).as_deref(), Some("Suma los números."));
// An argument list Spanish covers only in part stays English instead of mixing (the name
// is whatever the Spanish formula table calls SUMIFS).
let sumifs = sig("SUMIFS", Es, Language::Es);
assert!(sumifs.ends_with("(sum_range; criteria_range1; criteria1; ...)"), "{sumifs}");
assert_eq!(description("SUMIFS", Es, Language::Es).as_deref(), Some("Adds the cells that meet every condition."));
assert_eq!(sig("IF", En, Language::En), "IF(logical_test, [value_if_true], [value_if_false])");
}
}
+49
View File
@@ -12,6 +12,7 @@ use serde::{Deserialize, Serialize};
mod cs;
mod de;
mod es;
mod es_functions;
mod fr;
mod ko;
mod pt;
@@ -132,6 +133,34 @@ impl Language {
};
translated.unwrap_or_else(|| text.rsplit_once('|').map_or(text, |(_, label)| label))
}
/// A worksheet function's description in this language, looked up by the English text of the
/// function catalog; `None` when untranslated, and the help shows the English.
pub fn function_description(self, english: &str) -> Option<&'static str> {
lookup(self.function_help()?.0, english)
}
/// A function argument name in this language, given as the catalog spells it without
/// brackets or index (`number`, `logical_test`).
pub fn function_argument(self, english: &str) -> Option<&'static str> {
lookup(self.function_help()?.1, english)
}
/// The function help tables (descriptions, argument names) of the languages that have them.
fn function_help(self) -> Option<(HelpTable, HelpTable)> {
match self {
Self::Es => Some((es_functions::DESCRIPTIONS, es_functions::ARGUMENTS)),
_ => None,
}
}
}
/// English text and its translation, sorted by the English.
type HelpTable = &'static [(&'static str, &'static str)];
fn lookup(table: HelpTable, english: &str) -> Option<&'static str> {
let i = table.binary_search_by(|(key, _)| (*key).cmp(english)).ok()?;
table.get(i).map(|(_, translated)| *translated)
}
/// Deserializes a saved language leniently: an unknown or malformed value (a language removed in a
@@ -594,6 +623,26 @@ const JAPANESE: &[(&str, &str)] = &[
mod tests {
use super::*;
#[test]
fn function_help_is_sorted_and_keyed_by_current_catalog_text() {
// Lookups binary-search the tables. A key that no longer matches the catalog (an English
// description reworded, an argument renamed) would quietly show English again.
let catalog = gridcraft_engine::cmd::formulas::function_list();
let descriptions: std::collections::HashSet<&str> = catalog.iter().filter_map(|f| f["description"].as_str()).collect();
let arguments: std::collections::HashSet<&str> =
catalog.iter().filter_map(|f| f["signature"].as_str()).flat_map(crate::formula_locale::argument_names).collect();
for language in Language::ALL {
let Some((description_table, argument_table)) = language.function_help() else { continue };
for (table, catalog_text) in [(description_table, &descriptions), (argument_table, &arguments)] {
assert!(table.windows(2).all(|pair| pair[0].0 < pair[1].0), "{language:?} function help is not sorted and unique");
for (english, translated) in table {
assert!(catalog_text.contains(english), "{language:?} translates {english:?}, which the function catalog doesn't use");
assert!(!translated.is_empty(), "{language:?}: empty translation of {english:?}");
}
}
}
}
#[test]
fn translations_are_unique_and_preserve_unknown_text() {
for (i, (en, ja)) in JAPANESE.iter().enumerate() {
+35
View File
@@ -0,0 +1,35 @@
//! Spanish help for the worksheet functions. It covers the 10 common functions Spanish had help
//! for (SUM, AVERAGE, IF…); the others keep their English help.
/// Function descriptions.
pub(super) const DESCRIPTIONS: &[(&str, &str)] = &[
("Adds all the numbers in the arguments.", "Suma los números."),
("Adds the cells that meet a condition.", "Suma las celdas que cumplen un criterio."),
("Arithmetic mean of the numbers.", "Devuelve el promedio de los números."),
("Counts the cells that meet a condition.", "Cuenta las celdas que cumplen un criterio."),
("Counts the numbers.", "Cuenta las celdas que contienen números."),
(
"Finds a value in the first column of a table and returns the value in the same row of another column.",
"Busca un valor en la primera columna de una tabla.",
),
("Returns one value when a condition is TRUE and another when it is FALSE.", "Devuelve un valor según una condición."),
("Returns the current date.", "Devuelve la fecha actual."),
("Returns the logical value FALSE.", "Devuelve el valor lógico FALSO."),
("Returns the logical value TRUE.", "Devuelve el valor lógico VERDADERO."),
];
/// Argument names without brackets or index (`number`, not `[number2]`).
pub(super) const ARGUMENTS: &[(&str, &str)] = &[
("col_index_num", "indicador_columnas"),
("criteria", "criterio"),
("logical_test", "prueba_lógica"),
("lookup_value", "valor_buscado"),
("number", "número"),
("range", "rango"),
("range_lookup", "ordenado"),
("sum_range", "rango_suma"),
("table_array", "matriz"),
("value", "valor"),
("value_if_false", "valor_si_falso"),
("value_if_true", "valor_si_verdadero"),
];
+1 -1
View File
@@ -1780,7 +1780,7 @@ fn formulas(app: &mut SheetApp, ui: &mut Ui) {
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();
let desc = crate::formula_locale::description(n, locale, app.ui.language).unwrap_or_default();
if ui.button(name).on_hover_text(desc).clicked() {
app.run_or_alert("formulas.insertFunction", json!({"name": n}));
ui.close();
+10 -3
View File
@@ -1,6 +1,6 @@
# Localization parity
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (Format Cells dialog looks its labels up in the catalog) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-15 · **Change:** minor (function help follows the interface language) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4
Per-language status against Excel, which ships its UI in dozens of languages (Windows language
packs cover 100+), with localized function names, argument separators, number and date formats,
@@ -16,8 +16,14 @@ right-to-left sheets and full complex-script support. Part of
each. The tables are used by
the ribbon, sheet tabs and widgets (`ribbon.rs`, `tabs.rs`, `widgets.rs`), the Format Cells
dialog, every dialog title and the shared OK/Cancel buttons; the other dialogs, menus,
context menus, task panes, status messages, command labels and function descriptions are still
English literals. Untranslated labels fall back to English.
context menus, task panes, status messages and command labels are still English literals.
Untranslated labels fall back to English.
- **Function help** (the argument hint, autocomplete, Insert Function and the Formulas-tab
menus) follows the interface language through per-language tables keyed by the catalog's
English text (`i18n/<lang>_functions.rs`): descriptions, and argument names from which the
hint is assembled with the formula syntax's function name and separator. Spanish translates
the 10 common functions it had help for (SUM, AVERAGE, IF…); other languages show the English
help.
- **About 2,200 user-visible strings** in all (heuristic source scan: ~1,700 UI and command
strings plus ~500 function descriptions), so each shipped language covers **~12%** (measured
count / estimated total).
@@ -78,6 +84,7 @@ language (none so far) and of localized function names.
| Date | Change | Summary |
|---|---|---|
| 2026-10-15 | minor | Function help in the interface language: argument hints, autocomplete, Insert Function (search, categories) and the Formulas-tab menus read per-language tables of descriptions and argument names keyed by the catalog's English text; Spanish keeps its 10 functions in the new format |
| 2026-10-12 | minor | Format Cells looks up its tabs, labels, options and note through `Language::tr` (Fill tab keyed `Format Cells\|Fill`); dialog titles and OK/Cancel too. Labels already in a table (Number, Currency, Fill…) show translated; the rest fall back to English until the tables add them |
| 2026-10-12 | minor | Brazilian Portuguese catalog (`i18n/pt.rs`) grows from 377 to 923 entries: every key the other catalogs translate, including the dialog strings #669 starts looking up (~42%) |
| 2026-10-11 | minor | German ribbon catalog (`i18n/de.rs`, 266 labels, ~12%); `de` locale tags (de-DE, de-AT, de-CH…) resolve to German. PR #163 (localized German formulas/numbers) still open |