Localization (§12)
Day localizes with Mozilla Fluent. Text is a key resolved against
the current locale; the current locale is a Signal, so every tr() binding re-runs on a locale
switch, followed by one incremental relayout.
use day::prelude::*;
res::locales::install(); // every locale under resource/locales/
label(tr("greeting").arg("name", user_name)) // reactive, localized
set_locale("fr"); // every visible string updates
The catalog is the directory (§18.5)
res::locales::install() is generated by build.rs from resource/locales/. Adding a
language is adding a directory, with no source list to keep in step:
resource/locales/en/app.ftl → res::locales::CATALOG = &[("ar", …), ("en", …), ("fr", …)]
resource/locales/fr/app.ftl res::locales::DEFAULT = "en"
resource/locales/ar/app.ftl res::locales::install()
DEFAULTis the fallback locale:enwhen the app ships it, else the first tag alphabetically. For a different fallback, keep the generated catalog and name your own:install_locales("fr", res::locales::CATALOG).- Several
.ftlfiles in one locale directory (app.ftl,errors.ftl, …) concatenate into that locale’s bundle, so a large catalog can be split by area. ALLlists every bundled locale as(tag, display name): the data a language picker needs. The display name is the catalog’s ownlanguage_namemessage (each language naming itself:language_name = Français), read at build time; a catalog without one falls back to its tag. Switch at runtime withday::set_locale(tag). Strings re-resolve live, but layout direction is fixed at launch, so an LTR↔RTL change fully applies on the next start.- The directory is a build input (
cargo:rerun-if-changed), so a new, renamed, or deleted locale reaches the next build without acargo clean.
The underlying call is still public and unchanged: install_locales(default, &[(tag, source)])
takes any list an app assembles itself (locales fetched at runtime, a subset chosen per build).
The generated catalog is the zero-maintenance default, not a replacement.
Adding a language: day localize add
Adding the directory by hand works, but day localize add <tag> does the whole job: the Fluent
directory, the store/<tag>/ listing text, the Xcode knownRegions entry, and the locale list in
website/site.toml. day new app --locales <tags> runs the same code path per tag, so a fresh
project and an existing one mean the same thing by “add a locale”. day localize list reports any
surface that has drifted.
New Fluent files are the default locale’s, copied under a TODO: translate header: a
complete-but-untranslated locale that the key lints still track, rather than an empty directory
nothing checks. The CLI cannot translate an app’s own strings, since it does not know what they
mean.
It does know the strings it wrote. For the handful of keys day new app scaffolds and shows
on the opening screen (home_greeting, home_welcome, and the four nav_* labels), the CLI
carries real translations for 20 languages (crates/day-cli/src/starter_l10n.rs) and writes those
instead of an English copy, interpolating the project’s own title. A generated app therefore
greets its user in their language on first launch, and the header records how many lines arrived
translated:
# TODO: translate — 6 starter string(s) translated; the rest copied from en/ by `day localize add ja-JP`.
A tag the table does not carry simply gets the English copy, so the fallback is the old behaviour rather than a failure.
Checked keys: res::str::…() (§18.5)
tr("…") is stringly-typed: a typo or a wrong .arg name only shows up at runtime as ⟨key⟩. Day’s
build.rs (day_build::generate_resources(), wired into day new) also generates a function per
Fluent key under res::str, so the same text is checked at compile time and autocompletes:
label(res::str::greeting(user_name)) // == tr("greeting").arg("name", user_name)
label(res::str::counter_value(count)) // params come from the message's { $variables }
label(res::str::nav_home()) // 0-param keys are nullary functions
- The function’s signature mirrors the message’s parameters (each
impl IntoFArg, so it accepts&str/String/i64/f64/Signal), so a missing key or wrong argument count is a build error. - A variable used as a plural /
selectselector ({ $count -> [one]… }) is typedimpl IntoNumberFArginstead, so you can’t pass a string where CLDR plural rules need a number:
(A stringres::str::counter_value(count) // ok: i64 / f64 / Signal<i64|f64> res::str::counter_value("3") // compile error: &str: IntoNumberFArg is not satisfiedselectsuch as$gender -> [male] [female]is not forced numeric.) - Each function’s doc comment shows the reference-locale value, so IDE hover reveals the actual
text, e.g.
/// `greeting` — `Hello, { $name }!`. - Keys must be valid Rust identifiers → snake_case (
nav_home, not the Fluent-legalnav-home);day-buildfails the build with a rename hint otherwise. - All locales must agree on a key’s parameter names:
en{ $name }vsfr{ $nom }is a build error (numeric-ness is OR-ed across locales, so a plural in any locale makes the param numeric). - Using the functions is optional:
tr("…")stays for keys built at runtime, andday lintcounts ares::str::keyreference as a use just liketr("key").
Fluent parsing is centralized: the codegen,
day lint’s coverage checks (day_build::message_keys), and the runtime resolver (fluent-bundle) all usefluent-syntax, so what the tooling accepts is what resolves at runtime.
Formatted values: NUMBER() and DATETIME()
Every bundle (app and core, registered automatically by day-l10n) provides icu4x-backed
formatting, so translations render numbers and dates ICU-correctly for their locale with zero app
setup:
price = { NUMBER($n, minimumFractionDigits: 2) }
discount = { NUMBER($p, style: "percent") }
last_saved = Saved { DATETIME($when, dateStyle: "long", timeStyle: "short") }
- Plain
{ $n }interpolations localize too (a bundle-wide formatter, not just the explicit calls):1234567.891renders1,234,567.891inen,1.234.567,891inde,1 234 567,891(narrow no-break space) infr; locales that resolve to a non-Latin numbering system (e.g.ar-EG→ Arabic-Indic digits) get their own digits. Plural/selectstill selects on the numeric value. NUMBERoptions (ECMA-402 names):useGrouping,minimumIntegerDigits,minimum/maximumFractionDigits(default max 3, so float noise like0.30000000000000004never reaches a translation),minimum/maximumSignificantDigits,style: "decimal" | "percent". Percent is a documented v1 approximation (×100 + a localized percent sign);style: "currency"is not implemented yet: it formats as a plain decimal andday lintflags it.DATETIMEinput is civil and zoneless, matchingday-piece-datetime’s conventions: ISO-8601 strings ("2026-07-18","14:45[:30]","2026-07-18T14:45") or a number of epoch seconds rendered as UTC. Options:dateStyle/timeStyle∈full|long|medium|short|none(defaults: medium date, short time, by input shape). Formatting is fixed-Gregorian (the small data path); unparseable input echoes back visibly rather than erroring.day lintvalidates every call across every locale file: unknown functions (day::lint::unknown-function), misspelled options or bad values (day::lint::bad-format-option), and not-yet-supported options (day::lint::unsupported-format-option).
Sorting: locale-aware collation
day::compare(a, b), day::compare_in(locale, a, b), and day::sort_localized(&mut items)
(prelude: sort_localized) compare with icu4x’s collator instead of code points: French sorts
cote < coté < côte, and Chinese sorts by pinyin (北京 < 广州 < 上海), or by stroke order
via a locale extension, compare_in("zh-u-co-stroke", …). compare/sort_localized read the
locale signal (tracked), so a sort inside a reactive closure re-runs on locale switch:
label(move || {
let mut fruits = localized_fruit_names();
sort_localized(&mut fruits); // re-sorts when the locale changes
fruits.join(" · ")
})
Searching: localized match
day::matches_search(text, query) and day::matches_search_in(locale, text, query) answer the
question a search field asks: does this row match what the user typed? The rule is
case-insensitive prefix of any word. In an English UI, s matches
| title | why |
|---|---|
| Canvas & shapes | shapes |
| Device & sensors | sensors |
| Platform services | services |
| Stack | Stack |
and not “Toolbars” or “Controls”, whose only s is inside a word. An empty query matches
everything, so an empty box filters nothing.
The start of the text is always a word start, whatever the segmenter reports, so a title
always matches itself and any leading prefix of it: canvas & matches “Canvas & shapes”, and a
localized title typed verbatim is a query that works in every locale (which is how a dayscript
filters a sidebar without knowing the language). This matters most where the segmenter types a
whole run as not-word-like: a Han title like 堆栈 has no interior word start under the invariant
break options, and without the leading one it could not be matched at all. matches_search reads the locale signal (tracked);
matches_search_in takes the locale explicitly and reads nothing.
let hits = titles.iter().filter(|t| matches_search(t, &query.get()));
Two icu4x components make this correct outside English.
Word segmentation finds where words begin. Splitting on spaces is an English assumption:
日本語入力 is two words to a reader and one to split_whitespace, so search would find nothing
in Chinese, Japanese, Thai, Khmer, Lao or Burmese. The segmenter carries dictionaries and LSTM
models for exactly those scripts: matches_search_in("ja", "日本語入力", "入力") is true, and
"語" (mid-word) is false. Punctuation and spaces are not word starts, so the & in
“Canvas & shapes” is not somewhere a search can begin.
Case folding, not to_lowercase: folding is the operation Unicode defines for caseless
matching. Straße matches STRASSE, and Σ/σ/ς match each other. Turkish and Azerbaijani
get the Turkic variant, which keeps the dotted and dotless I apart: in tr, i does not
match Irmak, and ı does; in en, i matches it.
Because the word start at offset 0 is a candidate like any other, a multi-word query works with
no separate rule: canvas & matches “Canvas & shapes”.
[!NOTE] Matching is on case only.
édoes not matche, andädoes not matcha. Accent- insensitive search would mean comparing at the collator’s primary strength, which is a different (and slower) operation than a prefix test; it is a possible follow-up, not a current behaviour.
The segmenter’s auto models are the reason this section’s data footprint is not free. See the
next section.
Locale data: thinned per app
The icu4x components ship compiled_data for every locale (~1.5 MB of a release binary with all
three formatters linked). day build thins that to the locales the app DECLARES (the
resource/locales/* dirs plus the core catalog) by baking a data directory once (cached in
~/.day/icu) and pointing the build at it via ICU4X_DATA_DIR; unused components are
dead-code-eliminated regardless. Bare cargo builds simply embed the full data. Baking needs a
one-time CLDR source fetch (~100 MB, cached); DAY_NO_ICU_FETCH / DAY_ICU_FULL_DATA opt out.
See docs/environment.md “Locale data”.
Two layers: the app catalog and the core catalog
There are two tiers of Fluent bundles:
- App catalog: the locales your app registers (
res::locales::install(), orinstall_localesdirectly). It holds your keys and your translations. - Core catalog: a built-in set of standard UI strings the framework itself needs (dialog
buttons, standard menu commands), shipped inside
day-l10nin several languages (English, French, Spanish, German, Japanese, Simplified Chinese). Always present, even beforeinstall_locales.
Lookup order for any key: app[locale] → app[default] → core[locale] → core English. So your
strings always win, and the core catalog is the fallback for the day-* keys the framework emits and
your app didn’t define. You can override any core string just by defining the same key in your own
catalog.
Because the engine (day-l10n) sits low in the crate graph, the central crates localize their own UI
without the app doing anything: dialog buttons and standard menu-command labels come out in the
user’s language automatically.
Core strings the framework provides
Keys are namespaced day-*. The catalog covers the strings Day emits itself:
| Purpose | Keys |
|---|---|
| Dialog buttons | day-ok day-cancel day-yes day-no day-done day-save day-close day-delete |
Menu commands (MenuRole) | day-cut day-copy day-paste day-select-all day-undo day-redo day-about day-quit day-preferences day-minimize day-fullscreen day-new-window |
| App-name commands | day-about-app (About {$app}), day-quit-app (Quit {$app}), day-edit |
| Window management (docs/windows.md) | day-window day-zoom day-bring-all-front |
| Settings pieces (day-piece-settings) | day-settings-language day-settings-theme day-theme-light day-theme-dark day-theme-system |
The catalog ships en, fr, es, de, ja, zh, and ar (Arabic joined with the windows work; the
showcase/sample-app ar CI variants localize the core strings instead of falling back to
English).
Concretely:
confirm(...)/prompt(...)default their buttons today-ok/day-cancel. In French the buttons read OK / Annuler;.confirm_label/.cancel_labelstill override.menu_role(MenuRole::Cut)(and the rest) get their label from the core catalog (Couper in French, Ausschneiden in German) instead of each backend hardcoding English.- The AppKit standard App menu (“About X” / “Quit X”) uses
day-about-app/day-quit-app, whose{$app}interpolation gives correct per-language word order (e.g. JapaneseDayを終了).
Adding a language for the core strings is a catalog/<lang>.ftl in day-l10n; adding a core key is
one line per language.
How it’s layered
day-reactive
└── day-l10n ← the engine: bundles, the locale Signal, format_in, the built-in core catalog
├── day-pieces (dialogs, menu-role labels) ← localize their own strings
├── day-appkit (menu chrome)
└── day-fluent ← adds the reactive `tr()` text source; re-exports the engine
day-fluent re-exports the engine, so the app-facing API (install_locales, tr, set_locale) is
unchanged. Core crates call day_l10n::t("day-cancel") (resolve once, in the current locale) for the
framework’s own one-shot strings.
Right-to-left locales
An RTL locale (Arabic, Hebrew, Farsi, …) flips the whole UI (resolved once at startup, from
DAY_LOCALE or the locale install_locales settles on; runtime set_locale switches strings
but not direction):
- Day’s layout engine mirrors every horizontal placement in the place pass (
day-core): rows reverse,leadingmeans right, padding swaps sides, the form label column right-aligns. No layout implementation knows about direction. Leaf CONTENT (canvas drawing, text runs) is not mirrored. Children whose frames are native-owned (nav pages in splitter panes / nav-controller views) place viaplace_child_nativeand are never mirrored. - Each toolkit enables its native RTL mode for widget-internal behavior: AppKit registers
AppleTextDirection(volatile, registration domain) beforeNSApplicationinit; UIKit forcessemanticContentAttributeon the window + content roots; GTK callsgtk_widget_set_default_direction(which also flips the Adw split view’s sidebar side); Qt switches label/field text direction only (its app-widesetLayoutDirectionwould re-mirror containers underneath Day’s absolute frames); Android sets the decor view’s layout direction (android:supportsRtlrides the manifest template).
The showcase ships an Arabic locale (--locale ar) exercising all of this; CI captures every
walkthrough screenshot in light/dark × en/fr/ar/zh-CN, and dayscript/rtl-check.yaml is a quick
local smoke-test.
Pseudolocale
Setting the locale to en-XA accents and expands every string (Cáncél ・ロング) to stress-test
layout for longer translations and non-Latin glyphs, without needing a real translation.