Localization
Day localizes with Mozilla Fluent, a message format built for the
grammar problems that printf-style formats handle badly: plurals, gender, and languages that
reorder everything. Localization is designed into Day’s core rather than added on top: the
locale is a reactive signal, every built-in string mechanism goes through it, and the test
tooling understands it.
Files and setup
Translations live in resource/locales/<lang>/app.ftl, one file per language:
# resource/locales/en/app.ftl
app-title = Field Notes
greeting = Hello, { $name }!
unread-count = { $count ->
[one] You have one unread note
*[other] You have { $count } unread notes
}
Register the catalogs once, at the top of your root function:
day_fluent::install(
"en",
&[
("en", include_str!("../resource/locales/en/app.ftl")),
("fr", include_str!("../resource/locales/fr/app.ftl")),
],
);
Using strings
tr(key) produces a localized, reactive text value that any text-accepting Piece takes
directly:
label(tr("app-title")).font(Font::Title)
label(tr("greeting").arg("name", user_name)) // arg can be a value, Signal, or closure
label(tr("unread-count").arg("count", unread)) // Fluent picks the plural form
button(tr("save")).action(save)
Because arguments accept signals, a localized message with a live value is one expression; when
unread changes, the label re-renders through the same fine-grained binding any other text uses,
and Fluent re-selects the plural category. For a plain String in non-UI code, t("key")
returns the formatted value once, not reactively.
Missing messages fall back per-message to the default locale, so a half-translated catalog ships degraded rather than broken. Day’s own strings (dialog buttons, menu roles) come from a built-in core catalog that your app catalog can override key by key.
Switching locale at runtime
The current locale is a Signal<String>, initialized from a CLI override, then the OS
preference, then your default:
set_locale("fr"); // every tr() binding re-runs; layout reflows for new text sizes
let l = locale().get(); // read (reactively, if inside a binding)
A locale switch is an ordinary reactive update: no restart, no tree rebuild. Longer German strings or shorter Chinese ones change measured text sizes, and incremental relayout handles the reflow.
Testing what you translated
Two tools make per-locale verification cheap:
day launch -p macos-appkit --locale fr --script dayscript/walkthrough.yaml
day launch -p macos-appkit --locale en-XA
The first runs your dayscript walkthrough under French; the CI configuration does exactly this, so the gallery screenshots double as translation review. Scripts can assert by Fluent key rather than literal text, so one script passes in every locale.
en-XA is the built-in pseudolocale: it accents every message’s text
(Ĥéĺĺó, Ádá!) without touching placeables, which makes unlocalized hardcoded strings jump out
visually. day lint complements it statically, flagging bare user-facing literals and unused or
missing keys.
Current limits
- What’s covered: your UI strings, and OS-facing metadata (the app’s display name and similar) conveyed into platform manifests at build time.
- What isn’t: text inside out-of-process UI (native file dialogs, permission prompts) follows
the system locale, not your in-app override; every framework shares this limit, but you’ll
notice it when testing with
--locale. - RTL: layout mirroring is designed into the layout engine (leading/trailing resolve at
placement), but there’s no
ar-XBRTL pseudolocale yet and RTL hasn’t had a dedicated CI leg. Treat right-to-left support as present-but-lightly-exercised, and test with a real RTL locale if you ship one. - Number/date formatting follows the locale through Fluent; for formatting outside messages you’re in ordinary Rust and choose your own crates.
The localization reference covers the mechanics in more depth.