Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 13 additions & 4 deletions ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

**Stage: alpha** · next: beta, ~25 points of "ready for real work" and ~160–240 h away

> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-23 · **Change:** minor (accounting negatives such as `$(1,234)` read as numbers; INDEX with one index on a 2-D reference is #REF! as in Excel; earlier: Page Setup paper sizes: JIS B4/B5/B6, the Japanese postcards and envelopes, and A6; long flat operator chains up to Excel's formula limit) · **Target:** Microsoft Excel (Microsoft 365)
> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-24 · **Change:** minor (French localization reconciled with upstream translation tests and moved UI tests; native menu refresh and French formula names/errors retained; accounting negatives read as numbers; INDEX with one index on a 2-D reference is #REF!) · **Target:** Microsoft Excel (Microsoft 365)

GridCraft aims at full Microsoft Excel parity, and at being better in places: faster, open (XLSX
is the native format), fully scriptable by agents (MCP, control channel, CLI), and on the web.
Expand Down Expand Up @@ -63,7 +63,7 @@ collaboration, mobile, AI).
| File formats | ~50% | 80–120 | [file-format-parity.md](docs/file-format-parity.md) |
| Performance | ~35% | 35–55 | [target-app-parity.md](docs/target-app-parity.md#performance) |
| Stability | ~60% | 15–25 | [gaps.md](docs/gaps.md) |
| Localization | ~10% (ribbon in 6 languages) | 100–165 | [localization-parity.md](docs/localization-parity.md) |
| Localization | ~10% (partial UI catalogs; French formulas) | 100–165 | [localization-parity.md](docs/localization-parity.md) |
| Hardware (multi-core calc, GPU, pen, multi-window) | ~40% | 25–40 | [hardware-parity.md](docs/hardware-parity.md) |
| Platforms | ~80% (no iOS/Android; ahead on Linux, BSD, RISC-V) | 30–60 | [target-app-parity.md](docs/target-app-parity.md#platforms) |
| Ecosystem / add-ins | ~10% (VBA out of scope; own scripts, MCP, CLI) | 25–40 | [target-app-parity.md](docs/target-app-parity.md#ecosystem-and-ai) |
Expand Down Expand Up @@ -96,7 +96,8 @@ added 10-11), the
interface follows the system language, and CJK, Thai and Hebrew text falls back to system fonts
(#38). Japanese (10-17) and Brazilian Portuguese also translate the dialog titles and labels,
message boxes, Backstage/File-view items, template names and native menus: 943 strings each, ~43%.
The remaining dialogs, menus and messages are English; formulas and number formats are en-US only.
UI translations remain partial. French formulas cover all 514 registered names with French
separators; Spanish has a smaller alias table. Stored formulas remain canonical English.
Localized German formulas and numbers are in PR #163. Detail: [localization-parity.md](docs/localization-parity.md).

| Language | Code | Status | UI strings |
Expand All @@ -106,7 +107,7 @@ Localized German formulas and numbers are in PR #163. Detail: [localization-pari
| Spanish | es | none | 0% |
| Hindi | hi | none | 0% |
| Arabic | ar | none (no RTL layout, no shaping) | 0% |
| French | fr | none | 0% |
| French | fr | partial UI; all formula names | not remeasured |
| Portuguese | pt-BR | menus, File view, dialogs | ~43% |
| Indonesian | id | none | 0% |
| Japanese | ja | menus, File view, dialogs, messages | ~43% |
Expand Down Expand Up @@ -135,6 +136,10 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in

| Date | What landed |
|---|---|
| 2026-10-24 | French localization reconciled with the updated translation tests and consolidated UI test suite. |
| 2026-10-23 | Native macOS menus reflect language changes in the same frame, without waiting for another input event. |
| 2026-10-23 | French formula error literals and corrected MAX.SI.ENS/MIN.SI.ENS names (adapted from #2310); registry coverage and uniqueness tested, canonical file/API syntax preserved. |
| 2026-10-23 | French formula localization reconciled with upstream function help, Unicode name matching and long flat operator-chain support; regression coverage retains both French and upstream behavior. |
| 2026-10-23 | Accounting negatives in text: `$(1,234)`, `$ (5)` and `(5)%` read as negative numbers when typed, coerced by arithmetic or passed to VALUE, like Excel 365; `(-5)`, `((5))`, `$5%` and dates or times in parentheses stay text |
| 2026-10-23 | Calc: `INDEX(A1:C3,2)` (one index on a reference of several rows and columns) is `#REF!` as in Excel, instead of the whole row; `INDEX(A1:C3,2,)` still gives the row |
| 2026-10-22 | Formula hardening: digits too many for a finite number are text, so `"1 <400 digits>/<400 digits>"` no longer coerces to NaN; a NaN column index in VLOOKUP, HLOOKUP and the D-functions is `#VALUE!` instead of an index underflow; REGEXREPLACE stops building a result as soon as it can only be too long (a worst case went from 1 GB to 7 MB) (#2163, #2162) |
Expand Down Expand Up @@ -165,6 +170,8 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in
| 2026-10-13 | XLSX: `[Content_Types].xml`, `_rels/.rels`, the workbook rels and `xl/workbook.xml` are the first zip entries (document properties last), so libmagic and PHP `finfo` detect the file type even with large properties (#1090) |
| 2026-10-13 | Conditional formatting: a rule gathers range statistics only when it reads them (top/bottom, above average, duplicates, scales, bars, icons) and borrows them instead of copying them per cell, so the per-frame lookup of an expression or cell-value rule no longer visits its whole range (#1098, part 1) |
| 2026-10-12 | Next/previous sheet navigation stops at the first/last visible worksheet instead of activating a hidden boundary sheet; the current selection is retained |
| 2026-10-16 | Native macOS menus and the quick-access menu use the interface language; French labels added, live language changes preserve commands and shortcuts. |
| 2026-10-16 | French formula names cover all 514 registered names; French entry/display/autocomplete use existing locale support; XLSX remains canonical English. |
| 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) |
Expand Down Expand Up @@ -226,6 +233,8 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in

| Date | Change | Summary |
|---|---|---|
| 2026-10-24 | minor | French localization reconciled with upstream translation-test policy and consolidated UI tests; French formula coverage and native-menu behavior retained |
| 2026-10-23 | minor | Native macOS menus refresh after the UI pass when the language changes |
| 2026-10-23 | minor | Progress log: accounting negatives in number text |
| 2026-10-23 | minor | Progress log: INDEX with one index on a 2-D reference is #REF! |
| 2026-10-22 | trivial | Progress log: formula hardening (#2163, #2162) |
Expand Down
9 changes: 7 additions & 2 deletions apps/gridcraft/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -32,9 +32,9 @@ impl eframe::App for App {
#[cfg(target_os = "macos")]
{
if self.1.is_none() && std::env::var_os("GRIDCRAFT_NO_NATIVE_MENU").is_none() {
self.1 = Some(native_menu::NativeMenu::install(ctx));
self.1 = Some(native_menu::NativeMenu::install(ctx, self.0.ui.language));
}
if let Some(m) = &self.1 {
if let Some(m) = &mut self.1 {
m.poll(&mut self.0, ctx);
}
apple_events::poll(&self.2, &mut self.0, ctx);
Expand All @@ -56,6 +56,11 @@ impl eframe::App for App {
}
fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) {
self.0.ui(ui);
// Apply language changes made by the UI without waiting for another repaint.
#[cfg(target_os = "macos")]
if let Some(m) = &mut self.1 {
m.poll(&mut self.0, ui.ctx());
}
}
fn on_exit(&mut self) {
print::finish_pending_jobs();
Expand Down
81 changes: 66 additions & 15 deletions apps/gridcraft/src/native_menu.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
use std::str::FromStr;
use std::sync::mpsc::{Receiver, channel};

use gridcraft_ui_egui::SheetApp;
use gridcraft_ui_egui::{SheetApp, i18n::Language};
use muda::accelerator::Accelerator;
use muda::{CheckMenuItem, Menu, MenuEvent, MenuItem, PredefinedMenuItem, Submenu};
use serde_json::{Value, json};
Expand Down Expand Up @@ -35,7 +35,7 @@ fn tree() -> Vec<(&'static str, Vec<Entry>)> {
],
),
(
"Edit",
"menu|Edit",
vec![
("Undo", "edit.undo", None),
("Redo", "edit.redo", None),
Expand Down Expand Up @@ -181,8 +181,27 @@ fn tree() -> Vec<(&'static str, Vec<Entry>)> {
]
}

enum Label {
Item(MenuItem),
Submenu(Submenu),
Predefined(PredefinedMenuItem),
}

impl Label {
/// Updates the displayed label without replacing the native menu item.
fn set_text(&self, text: &str) {
match self {
Self::Item(item) => item.set_text(text),
Self::Submenu(menu) => menu.set_text(text),
Self::Predefined(item) => item.set_text(text),
}
}
}

pub struct NativeMenu {
_menu: Menu,
language: Language,
labels: Vec<(&'static str, Label)>,
events: Receiver<MenuEvent>,
theme_items: Vec<(&'static str, CheckMenuItem)>,
}
Expand All @@ -199,43 +218,55 @@ fn select_theme(app: &mut SheetApp, id: &str) -> bool {
}

impl NativeMenu {
pub fn install(ctx: &egui::Context) -> NativeMenu {
pub fn install(ctx: &egui::Context, language: Language) -> NativeMenu {
let menu = Menu::new();
let app_menu = Submenu::new("GridCraft", true);
let about = MenuItem::with_id("dialog:about", "About GridCraft", true, None);
let about = MenuItem::with_id("dialog:about", language.tr("About GridCraft"), true, None);
let mut labels = vec![("About GridCraft", Label::Item(about.clone()))];
let predefined = [
("Services", PredefinedMenuItem::services(Some(language.tr("Services")))),
("Hide GridCraft", PredefinedMenuItem::hide(Some(language.tr("Hide GridCraft")))),
("Hide Others", PredefinedMenuItem::hide_others(Some(language.tr("Hide Others")))),
("Show All", PredefinedMenuItem::show_all(Some(language.tr("Show All")))),
("Quit GridCraft", PredefinedMenuItem::quit(Some(language.tr("Quit GridCraft")))),
];
let _ = app_menu.append_items(&[
&about,
&PredefinedMenuItem::separator(),
&PredefinedMenuItem::services(None),
&predefined[0].1,
&PredefinedMenuItem::separator(),
&PredefinedMenuItem::hide(None),
&PredefinedMenuItem::hide_others(None),
&PredefinedMenuItem::show_all(None),
&predefined[1].1,
&predefined[2].1,
&predefined[3].1,
&PredefinedMenuItem::separator(),
&PredefinedMenuItem::quit(None),
&predefined[4].1,
]);
labels.extend(predefined.into_iter().map(|(key, item)| (key, Label::Predefined(item))));
let _ = menu.append(&app_menu);
let mut theme_items = Vec::new();
let mut window_menu = None;
for (title, entries) in tree() {
let sub = Submenu::new(title, true);
let sub = Submenu::new(language.tr(title), true);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
labels.push((title, Label::Submenu(sub.clone())));
for (label, id, acc) in entries {
if label == "-" {
let _ = sub.append(&PredefinedMenuItem::separator());
continue;
}
if id == "view.theme" {
let themes = Submenu::new(label, true);
let themes = Submenu::new(language.tr(label), true);
labels.push((label, Label::Submenu(themes.clone())));
for (mode, label) in [("system", "System"), ("light", "Light"), ("dark", "Dark")] {
let item = CheckMenuItem::with_id(format!("theme:{mode}"), label, true, false, None);
let item = CheckMenuItem::with_id(format!("theme:{mode}"), language.tr(label), true, false, None);
let _ = themes.append(&item);
theme_items.push((mode, item));
}
let _ = sub.append(&themes);
continue;
}
let accel = acc.and_then(|a| Accelerator::from_str(a).ok());
let item = MenuItem::with_id(id, label, true, accel);
let item = MenuItem::with_id(id, language.tr(label), true, accel);
labels.push((label, Label::Item(item.clone())));
let _ = sub.append(&item);
}
let _ = menu.append(&sub);
Expand All @@ -256,10 +287,19 @@ impl NativeMenu {
let _ = tx.send(e);
ctx.request_repaint();
}));
NativeMenu { _menu: menu, events: rx, theme_items }
NativeMenu { _menu: menu, language, labels, events: rx, theme_items }
}

pub fn poll(&self, app: &mut SheetApp, ctx: &egui::Context) {
pub fn poll(&mut self, app: &mut SheetApp, ctx: &egui::Context) {
if self.language != app.ui.language {
self.language = app.ui.language;
for (key, label) in &self.labels {
label.set_text(self.language.tr(key));
}
for ((_, item), key) in self.theme_items.iter().zip(["System", "Light", "Dark"]) {
item.set_text(self.language.tr(key));
}
}
while let Ok(ev) = self.events.try_recv() {
let id = ev.id.as_ref().to_string();
// Text selection belongs to the inline editor, not the worksheet clipboard/undo.
Expand Down Expand Up @@ -337,6 +377,17 @@ mod tests {
assert!(gridcraft_engine::find_command("file.exportPdf").is_some());
}

#[test]
fn french_catalog_covers_every_native_menu_entry() {
for (title, entries) in tree() {
for key in std::iter::once(title).chain(entries.iter().map(|entry| entry.0)) {
if !matches!(key, "-" | "Format" | "Normal" | "Zoom…") {
assert_ne!(Language::Fr.tr(key), key, "missing French menu label: {key}");
}
}
}
}

#[test]
fn theme_choices_use_the_shared_preference_command() {
let mut app = SheetApp::new(gridcraft_engine::Session::new(), Default::default());
Expand Down
15 changes: 14 additions & 1 deletion apps/gridcraft/tests/mac_menu_appkit.rs
Original file line number Diff line number Diff line change
Expand Up @@ -20,13 +20,26 @@ fn main() {
nsapp.finishLaunching();

let ctx = egui::Context::default();
let _menu = native_menu::NativeMenu::install(&ctx);
let mut menu = native_menu::NativeMenu::install(&ctx, gridcraft_ui_egui::i18n::Language::En);

let bar = nsapp.mainMenu().expect("a main menu");
let tops: Vec<String> = (0..bar.numberOfItems()).filter_map(|i| bar.itemAtIndex(i)?.submenu()).map(|m| m.title().to_string()).collect();
assert_eq!(tops, ["GridCraft", "File", "Edit", "View", "Insert", "Format", "Tools", "Data", "Window", "Help"]);
assert_eq!(nsapp.windowsMenu().map(|m| m.title().to_string()).as_deref(), Some("Window"), "AppKit knows the Window menu");

let mut app = gridcraft_ui_egui::SheetApp::new(gridcraft_engine::Session::new(), Default::default());
app.ui.language = gridcraft_ui_egui::i18n::Language::Fr;
menu.poll(&mut app, &ctx);
let tops: Vec<String> = (0..bar.numberOfItems()).filter_map(|i| bar.itemAtIndex(i)?.submenu()).map(|m| m.title().to_string()).collect();
assert_eq!(tops, ["GridCraft", "Fichier", "Édition", "Affichage", "Insertion", "Format", "Outils", "Données", "Fenêtre", "Aide"]);
let file = bar.itemAtIndex(1).and_then(|item| item.submenu()).expect("File menu");
assert_eq!(file.itemAtIndex(0).map(|item| item.title().to_string()).as_deref(), Some("Nouveau classeur"));
assert_eq!(nsapp.windowsMenu().map(|m| m.title().to_string()).as_deref(), Some("Fenêtre"));
app.ui.language = gridcraft_ui_egui::i18n::Language::En;
menu.poll(&mut app, &ctx);
assert_eq!(file.itemAtIndex(0).map(|item| item.title().to_string()).as_deref(), Some("New Workbook"));
assert_eq!(bar.itemAtIndex(2).and_then(|item| item.submenu()).map(|m| m.title().to_string()).as_deref(), Some("Edit"));

println!("mac_menu_appkit: ok");
}

Expand Down
32 changes: 32 additions & 0 deletions crates/engine/tests/french_formula_names.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
//! Every function the engine knows has a French name, so French entry never falls back to
//! English for a built-in (a missing row would let `=SOMME(…)` work but `=NEWFN(…)` show its
//! English name). A function added later fails here until it has its French row.
use gridcraft_engine::{calc::SPECIAL_FUNCTIONS, formula::FormulaLocale};

#[test]
/// Every registered function and special form has a French row.
fn every_known_function_has_a_french_name() {
let table = FormulaLocale::Fr.aliases();
let has = |name: &str| table.iter().any(|(en, _)| en.eq_ignore_ascii_case(name));
let mut missing: Vec<String> = gridcraft_functions::all()
.iter()
.map(|spec| spec.name.to_string())
.chain(SPECIAL_FUNCTIONS.iter().map(|name| name.to_string()))
.filter(|name| !has(name))
.collect();
missing.sort();
missing.dedup();
assert!(missing.is_empty(), "functions without a French name: {missing:?}");
}

#[test]
/// No function has two French names, and no French name stands for two functions.
fn french_names_are_unique_both_ways() {
let table = FormulaLocale::Fr.aliases();
for (i, (en, fr)) in table.iter().enumerate() {
for (en2, fr2) in &table[i + 1..] {
assert!(!en.eq_ignore_ascii_case(en2), "two rows for {en}");
assert!(!fr.eq_ignore_ascii_case(fr2), "{fr} names both {en} and {en2}");
}
}
}
Loading