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()
  • DEFAULT is the fallback locale: en when 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 .ftl files in one locale directory (app.ftl, errors.ftl, …) concatenate into that locale’s bundle, so a large catalog can be split by area.
  • ALL lists every bundled locale as (tag, display name): the data a language picker needs. The display name is the catalog’s own language_name message (each language naming itself: language_name = Français), read at build time; a catalog without one falls back to its tag. Switch at runtime with day::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 a cargo 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 / select selector ({ $count -> [one]… }) is typed impl IntoNumberFArg instead, so you can’t pass a string where CLDR plural rules need a number:
    res::str::counter_value(count)   // ok: i64 / f64 / Signal<i64|f64>
    res::str::counter_value("3")     // compile error: &str: IntoNumberFArg is not satisfied
    (A string select such 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-legal nav-home); day-build fails the build with a rename hint otherwise.
  • All locales must agree on a key’s parameter names: en { $name } vs fr { $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, and day lint counts a res::str::key reference as a use just like tr("key").

Fluent parsing is centralized: the codegen, day lint’s coverage checks (day_build::message_keys), and the runtime resolver (fluent-bundle) all use fluent-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.891 renders 1,234,567.891 in en, 1.234.567,891 in de, 1 234 567,891 (narrow no-break space) in fr; locales that resolve to a non-Latin numbering system (e.g. ar-EG → Arabic-Indic digits) get their own digits. Plural/select still selects on the numeric value.
  • NUMBER options (ECMA-402 names): useGrouping, minimumIntegerDigits, minimum/maximumFractionDigits (default max 3, so float noise like 0.30000000000000004 never 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 and day lint flags it.
  • DATETIME input is civil and zoneless, matching day-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 / timeStylefull|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 lint validates 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

titlewhy
Canvas & shapesshapes
Device & sensorssensors
Platform servicesservices
StackStack

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 match e, and ä does not match a. 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(), or install_locales directly). 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-l10n in several languages (English, French, Spanish, German, Japanese, Simplified Chinese). Always present, even before install_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:

PurposeKeys
Dialog buttonsday-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 commandsday-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 to day-ok/day-cancel. In French the buttons read OK / Annuler; .confirm_label/.cancel_label still 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. Japanese Dayを終了).

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, leading means 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 via place_child_native and are never mirrored.
  • Each toolkit enables its native RTL mode for widget-internal behavior: AppKit registers AppleTextDirection (volatile, registration domain) before NSApplication init; UIKit forces semanticContentAttribute on the window + content roots; GTK calls gtk_widget_set_default_direction (which also flips the Adw split view’s sidebar side); Qt switches label/field text direction only (its app-wide setLayoutDirection would re-mirror containers underneath Day’s absolute frames); Android sets the decor view’s layout direction (android:supportsRtl rides 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.