Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Internationalization Reference

Teksilo's i18n stack (teksilo-i18n + teksilo-i18n-macros) is reactive end-to-end and compile-time-validated. Translation keys live in .ftl files (Mozilla Fluent syntax); the proc macros parse those files at compile time and reject typos at build time, not at runtime; the runtime resolution is mediated by a thread-local I18nManager that owns one FluentBundle per locale and exposes signals for the active locale, the active layout direction, and a translation version counter that fires on locale changes and on .ftl hot reloads.

Mental model in one line:

I18nConfig → I18nManager → bundles + signals → tr! / NumberFormatter / ... → reactive widgets

End-to-end example: examples/internationalization.


Canonical app shape

use teksilo::app::TeksiloAppBuilder;
use teksilo::i18n::I18nConfig;
use teksilo::prelude::*;

fn main() {
    let config = I18nConfig::new()
        .source_locale("en-US".parse().unwrap())
        .supported_locales([
            "en-US".parse().unwrap(),
            "fr-FR".parse().unwrap(),
            "ar-SA".parse().unwrap(),
        ])
        .compile_in(&[
            ("en-US", &[include_str!("../locales/en-US.ftl")]),
            ("fr-FR", &[include_str!("../locales/fr-FR.ftl")]),
            ("ar-SA", &[include_str!("../locales/ar-SA.ftl")]),
        ])
        .auto_detect_os_locale(true)
        .fallback_locale("en-US".parse().unwrap())
        .framework_locales(teksilo::widgets::framework_locales());

    TeksiloAppBuilder::new()
        .theme(intui::light())
        .i18n(config)
        .initial_window(
            WindowConfig::new()
                .title("My App")
                .size(800, 600)
                .root(|tree, _state| tree.add(Root::new())),
        )
        .run();
}

Notes:

  • TeksiloAppBuilder::i18n(config) installs the resulting I18nManager on the thread-local; once installed, every tr! / tr_signal! / current_locale() / NumberFormatter call in the same thread routes through it.
  • compile_in registers .ftl strings (typically pulled in via include_str!) so they're embedded in the binary. Use compile_in_locales! for the multi-locale × multi-file case.
  • The framework's own widget strings come from teksilo::widgets::framework_locales() — this is what gives MenuBar, Dialog, accessibility labels, keystroke names, etc. their localized text. Apps almost always want this.
  • teksilo::prelude::* re-exports tr / tr_widget / LocalizedString / I18nConfig / LanguageIdentifier (gated on the i18n cargo feature). The reactive macros (tr_signal, tr_signal_widget) and the formatter types (NumberFormatter, TeksiloDateTimeFormatter, TeksiloDateTime, NumberStyle, DateStyle, TimeStyle) are reachable through the teksilo::i18n::* path — they're not in the prelude (yet), so import them explicitly.

I18nConfig

Source. Builder for the manager's initial state. Methods are chainable and order-independent.

MethodPurpose
new()Defaults: source en-US, auto_detect_os_locale = true, fallback_locale = en-US, no resources.
.source_locale(LanguageIdentifier)The locale the source .ftl is written in (used as the bundle of last resort).
.supported_locales(impl IntoIterator<Item = LanguageIdentifier>)What the app advertises as available; powers UI locale pickers and auto-detect filtering.
.fallback_locale(LanguageIdentifier)Locale to fall back to if neither user choice nor OS detection lands on a supported locale.
.user_locale(Option<LanguageIdentifier>)Explicit user override (e.g. read from SettingsStore). Beats both auto-detect and fallback.
.auto_detect_os_locale(bool)Toggle the sys-locale step in I18nManager::resolve_initial_locale.
.compile_in(&[(&str, &[&'static str])])Register compiled-in resources: per-locale arrays of .ftl source strings (typically include_str! outputs). Accumulates — see Composing catalogues.
.framework_locales(&'static [...])Register framework strings (teksilo::widgets::framework_locales()).
.override_widget_strings(&'static [...])App-supplied overrides for framework strings — pass after framework_locales to win in the tr_widget! lookup chain.
.runtime_override(LanguageIdentifier, PathBuf)Watch a .ftl file or a directory of them on disk and rebuild that locale's bundle on every save (translator workflow; not for production use). Pass the directory whenever the locale ships more than one file. See Hot reload.
.test_only(source: &str, msgs: &[(&str, &str)])Construct a config for headless tests with inline messages, skipping .ftl files entirely.
.with_locale(loc: &str, msgs: &[(&str, &str)])Add another locale's inline messages to a test_only config.

Test config example:

#![allow(unused)]
fn main() {
use teksilo_i18n::{I18nConfig, I18nManager};

let cfg = I18nConfig::test_only("en-US", &[
        ("greeting", "Hello, World!"),
        ("welcome", "Hello, { $name }!"),
    ])
    .with_locale("fr-FR", &[
        ("greeting", "Bonjour, le monde !"),
        ("welcome", "Bonjour, { $name } !"),
    ]);
let mgr = I18nManager::from_config(&cfg);
teksilo_i18n::thread_local::install(mgr.clone());
}

Composing catalogues from several crates

compile_in accumulates across calls, and repeated registrations of the same locale are merged into that locale's single bundle. An application can therefore compose its own catalogue with catalogues shipped by extensions, plugins or sibling crates, none of which has to know the others exist:

#![allow(unused)]
fn main() {
let mut cfg = I18nConfig::new()
    .source_locale("en-US".parse().unwrap())
    .supported_locales(["en-US".parse().unwrap(), "fr-FR".parse().unwrap()])
    .compile_in(compile_in_locales!(
        base = "../locales/", locales = ["en-US", "fr-FR"], files = ["main.ftl"],
    ));

for ext in &extensions {
    cfg = cfg.compile_in(ext.locales());   // merged into the same bundles
}
}

Two rules follow from Fluent's own semantics, and both are pinned by crates/teksilo-i18n/tests/compile_in_additive.rs:

  • First registration of a key wins. FluentBundle::add_resource keeps the existing definition and reports the duplicate on stderr; it does not override. Register the application's catalogue first, and have contributors namespace their keys (myext-panel-title) rather than rely on shadowing.
  • A locale only a contributor supplies still gets its own bundle. Adding fr-FR from an extension does not fold those strings into the application's en-US.

Compile-time key validation is per calling crate: tr! resolves against the .ftl files of the crate it is written in (see TEKSILO_I18N_SOURCE_DIR), so each contributor validates its own keys against its own catalogue.


Locale resolution precedence

I18nManager::resolve_initial_locale picks the active locale at startup using this precedence (first match wins):

  1. config.user_locale — explicit app choice (e.g. read from SettingsStore). Honored only if it's in supported_locales.

  2. OS locale, if auto_detect_os_locale is on (the default). The writer's whole preferred-language chain is read from the sys-locale crate and walked in order; the first entry any supported locale can serve wins. Each entry is offered three tiers, narrowest first, before the next entry is tried at all:

    1. exact — every subtag agrees;
    2. same language and script, any region — an OS-reported en-GB matches a supported en-US, and fr, fr-CA, fr-BE or fr-CH all match a supported fr-FR;
    3. same language, script named on only one sidezh-Hans matches a supported zh-Hans-CN. A script named on both sides and disagreeing is never bridged, so zh-Hans can not land on zh-Hant-TW.

    Per-entry rather than exact-across-the-whole-chain-first on purpose: a chain of ["fr-CA", "en-US"] means "French, and English if you must", so the near miss on the first entry must beat the exact hit on the second. An entry that does not parse as a language tag is stepped over.

  3. config.fallback_locale — returned unconditionally if neither of the above produced a hit. Defaults to en-US if not set; not validated against supported_locales, so make sure the fallback actually has a bundle.

The picked locale is later applied via manager.set_locale(...) — typically called by TeksiloAppBuilder at startup, or by app code in response to a settings change. set_locale itself does validate against supported_locales and silently no-ops on an unsupported target, so an out-of-tree fallback (e.g. a typo) silently degrades to whatever the active locale already was.


tr! / tr_widget!

Compile-time-validating proc macros. Source: teksilo-i18n-macros.

#![allow(unused)]
fn main() {
use teksilo::i18n::tr;

let label = tr!(greeting());                           // no args
let hello = tr!(welcome(name = user_name));            // one arg
let nested = tr!(auth::login_title());                 // namespaced key
}

Compile-time checks:

  • The macro looks for the source .ftl in this order: directory at TEKSILO_I18N_SOURCE_DIR (env var, dir of .ftl files) → single file at TEKSILO_I18N_SOURCE_PATH (env var, one .ftl file) → directory at $CARGO_MANIFEST_DIR/locales/en-US/ (auto-detected if it exists) → single file at $CARGO_MANIFEST_DIR/locales/en-US.ftl (the fallback). Both layouts are first-class — flat-file projects and multi-file auth.ftl/editor.ftl/... projects work without configuration.
  • It checks the message key exists, and that every named arg matches a $variable declared in the message. Missing key, missing arg, unknown arg — all produce compile_error! at the call site, with Levenshtein-based "did you mean" hints for typos.
  • Every parsed .ftl file is registered as a build dependency via an emitted include_bytes!, so cargo rebuilds the calling crate when the source file changes.

Key path → Fluent key mapping:

  • tr!(count_items()) → fluent key count-items (single underscore inside a segment becomes a dash).
  • tr!(auth::login_title()) → fluent key auth__login-title (the :: separator becomes __; pick keys accordingly).
  • __ inside a segment is reserved and rejected — use :: for nesting.

Runtime behaviour:

tr!(...) expands to a LocalizedString:

#![allow(unused)]
fn main() {
pub struct LocalizedString { /* resolver: Rc<dyn Fn() -> String> */ }
}

The resolver closure captures the args by clone and, when invoked, calls resolve_message(key, args) against the active manager's app bundle. Two ways to consume it:

  • ls.resolve_now() -> String — eagerly resolve once.
  • ls.to_signal() -> Signal<String> — reactive: re-resolves on every bump of the manager's version_signal, which fires on locale changes and .ftl hot reloads.

From impls let tr!(...) slot into common widget builder shapes:

#![allow(unused)]
fn main() {
impl From<String>           for LocalizedString  // literal, non-translated
impl From<&str>             for LocalizedString  // literal
impl From<LocalizedString> for Prop<String>      // Bound if manager installed, Static otherwise
impl From<LocalizedString> for String            // eager resolve_now()
}

tr_widget!(...) has the same surface but routes through the manager's framework-strings lookup chain (override active → framework active → override source → framework source → key placeholder). Used inside teksilo-widgets and any app crate that ships overrides for framework strings.

Fallback behaviour: when no manager is installed (e.g. low-level widget tests) or the active bundle lacks the key, the resolver returns the source-language reconstruction of the message — the macro pre-parses the source .ftl for simple-pattern messages (literal text + { $var } substitutions) and emits an inline fallback. Selectors, plural rules, function calls, and message references bail out and return the key as a placeholder.


tr_signal! / tr_signal_widget!

Reactive variant for Signal<T>-inside-translated-sentence — when a reactive numeric, string, or temporal value belongs in the middle of a localized message and the whole sentence must re-render when the value, the locale, or a .ftl hot reload fires.

#![allow(unused)]
fn main() {
let count: Signal<i64> = ctx.signal(0);
let price: Signal<f64> = ctx.signal(0.0);

let label: Signal<String> = tr_signal!(
    cart_summary(count = count, price = price)
);
// label re-renders on:
//   count.set(...)        — any arg signal change
//   price.set(...)        — any arg signal change
//   manager.set_locale(…) — locale change
//   reload_from_path(…)   — hot reload (version bump)
}

Argument shape: every named arg must be a Signal<T> where T: Clone + 'static and FluentValue: From<T> (which covers the standard numeric types, String, and TeksiloDateTime). For static values, plain tr! is the right tool — tr_signal! is purely for reactive interpolation.

The macro auto-clones the signal expressions, so the caller's handle survives:

#![allow(unused)]
fn main() {
let count = ctx.signal(0_i64);
let price = ctx.signal(0.0_f64);
let label = tr_signal!(cart_summary(count = count, price = price));
count.set(5);   // fine — count was cloned, not moved
}

Why this exists: the two non-macro alternatives both fail. Hand- rolling signal.zip(locale).map(|(v, _)| tr!(...).resolve_now()) silently drops .ftl hot-reload re-renders (bypasses the version signal). HStack-juxtaposing translated-prefix + numeric-widget hardcodes prefix-then-value word order, breaking i18n correctness in languages where the variable goes elsewhere in the sentence (Japanese, Arabic, Hindi). tr_signal! is the correct path for both.

Compile-time validation: identical to tr! — same KeyMap parser, same key-existence + arg-name checks. The only difference is the lowering: tr_signal! emits a Signal<String> subscribed to each arg signal plus the version signal via Signal::observe + attach_keepalive, instead of a LocalizedString resolver closure. Observer cleanup on drop is verified in tests/format_integration.rs — dropping the result signal returns the source signals' observer counts to baseline.

tr_signal_widget! mirrors tr_widget!: same surface, routes through the framework-strings lookup chain.


Locale-aware formatting

Numbers, dates, and times that change with the user's locale flow through one ICU4X-backed layer. Two consumer paths share the same cache, so a UI mixing translated and untranslated displays stays internally consistent on , vs ., grouping, currency suffixes, etc.

Source.

Bundle-side: NUMBER() / DATETIME() inside .ftl messages

I18nManager auto-installs a set_formatter callback on every bundle and registers a DATETIME Fluent function. So .ftl messages can use { NUMBER($v) } and { DATETIME($ts, dateStyle: "long") } and they render correctly across locales — no app-side wiring.

# locales/en-US.ftl
price-display = The price is { NUMBER($v) }
last-saved    = Last saved on { DATETIME($ts, dateStyle: "long") }
#![allow(unused)]
fn main() {
use teksilo::i18n::{tr, TeksiloDateTime};

tr!(price_display(v = 1234.56))     // "The price is 1,234.56"
tr!(last_saved(ts = TeksiloDateTime::from(some_jiff_zoned)))
}

For numeric args pass any f64/i32/u64/etc. (already covered by FluentValue: From<T>). For date/time args, wrap in TeksiloDateTime.

Signal-side: NumberFormatter / TeksiloDateTimeFormatter

For displays that don't go through translated messages — SpinBox values, TableView cells, status bars, numeric inputs:

#![allow(unused)]
fn main() {
use teksilo::i18n::{
    DateStyle, TeksiloDateTimeFormatter, NumberFormatter, NumberStyle,
};

// Plain decimal with locale-aware grouping.
let display: Signal<String> = NumberFormatter::new()
    .fraction_digits(2, 2)
    .format(price_signal);          // Signal<f64> → Signal<String>

// Currency: the locale's own symbol, positioned the way the locale
// positions it — "1 234,50 €" in fr-FR, "€1,234.50" in en-US.
let cost = NumberFormatter::new()
    .currency("EUR")                // implies NumberStyle::Currency
    .format(amount_signal);

// Percent: value × 100, then the locale's percent form — "12,5 %"
// in fr-FR, "%12,5" in tr-TR.
let progress = NumberFormatter::new()
    .percent()                      // implies NumberStyle::Percent
    .format(ratio_signal);

// Date/time.
let when = TeksiloDateTimeFormatter::new()
    .date_style(DateStyle::Long)
    .format(timestamp_signal);      // Signal<jiff::civil::DateTime> → Signal<String>
}

The format(...) method accepts impl Into<Prop<f64>> (resp. Prop<jiff::civil::DateTime>), so plain values work too:

#![allow(unused)]
fn main() {
let s = NumberFormatter::new().format(987_654.321_f64).get();
// "987,654.321" in en-US, "987 654,321" in fr-FR
}

Result signals re-render on:

  • The value signal firing (when bound to one).
  • manager.set_locale(...).
  • manager.reload_from_path(...) — version-signal bumps drive recomputation.

If no I18nManager is installed on the thread (low-level widget tests, isolated benchmarks), the formatter falls back to und locale — the result is non-empty but locale-naive.

NumberFormatter builder methods:

MethodDefaultPurpose
.style(NumberStyle)DecimalSet the style explicitly.
.currency(impl Into<String>)Set ISO-4217 code; implies Currency style. Renders the locale's symbol.
.percent()Implies Percent style; multiplies value by 100.
.fraction_digits(min: u8, max: u8)noneMin zero-pads; max rounds half-to-even.
.use_grouping(bool)trueToggle locale grouping separators.

TeksiloDateTimeFormatter builder methods:

MethodDefaultPurpose
.date_style(DateStyle)Medium if neither setLong/Medium/Short.
.time_style(TimeStyle)noneLong/Medium/Short.

.format(...) takes Prop<jiff::civil::DateTime>; .format_zoned(...) takes Prop<jiff::Zoned> (rendered as wall-clock value at the zoned datetime's zone).

TeksiloDateTime

The wrapper that bridges jiff types into Fluent's FluentValue type system as a FluentValue::Custom. Used as a tr! argument for the bundle-side DATETIME() function:

#![allow(unused)]
fn main() {
let now = jiff::Zoned::now();
let civil = jiff::civil::date(2026, 5, 4).at(14, 35, 0, 0);

TeksiloDateTime::from(now)              // From<jiff::Zoned>
TeksiloDateTime::from(civil)            // From<jiff::civil::DateTime>
TeksiloDateTime::from_zoned(now)        // explicit constructor
TeksiloDateTime::from_civil(civil)      // explicit constructor
}

TeksiloDateTime implements From<...> for FluentValue<'static>, so tr! accepts it as an argument value directly.

ICU coverage

Backed by icu_decimal 2.x, icu_datetime 2.x and icu_experimental 0.6, all with the compiled_data feature (CLDR baked into the binary; no runtime data provisioning).

  • Decimal — full locale-aware grouping, digit shaping, signs.
  • Percent — value × 100, then ICU's PercentFormatter. The sign is the locale's own and sits where the locale puts it: "12.5%" in en-US, "12,5 %" (no-break space) in fr-FR, "%12,5" in tr-TR.
  • Currency — ICU's CurrencyFormatter with the short symbol: "$1,234.50" in en-US, "1 234,50 $US" in fr-FR. Two consequences worth knowing:
    • ICU applies the currency's CLDR fraction precision, which overrides .fraction_digits(...). JPY renders "¥1,235", not "¥1,234.50". This is ECMA-402 behaviour.
    • .use_grouping(false) does not reach currency: the CurrencyFormatter constructors build their own DecimalFormatter and expose no seam to pass ours. A Currency style with no code, or a code ICU rejects, falls back to plain decimal rather than rendering a wrong currency.
  • DateTime — full ICU support via CompositeDateTimeFieldSet.

Reading numbers back: NumberSymbols

NumberFormatter is display-only and f64-based. For an editable numeric surface you need the other direction too, and you need it to agree with the display exactly — so NumberSymbols recovers a locale's separators, signs and digits from ICU's own formatted output rather than from a provider struct. It formats probe values through the same DecimalFormatter the display path uses and reads the separators out of the [parts] annotations, so the symbols are the ones the formatter actually emits, by construction.

#![allow(unused)]
fn main() {
use teksilo::i18n::{NumberSymbols, delocalize_number};

let sym = NumberSymbols::current();          // or ::for_locale(&lang)
sym.decimal_separator();                     // "," in fr-FR
sym.group_separator();                       // "\u{202f}" in fr-FR
sym.minus_sign();                            // "−" (U+2212) in sv-SE
sym.zero_digit();                            // '٠' in ar-EG

// Display → C locale, ready for `str::parse`.
delocalize_number("1 234,56");               // Some("1234.56") in fr-FR

// C locale → display. Takes and returns a *string*, so an i64 past
// 2^53 keeps full precision — an f64 round-trip would not.
sym.localize("9007199254740993", true);      // "9,007,199,254,740,993"
}

Parsing is lenient, matching ICU's default: whitespace is ignored anywhere, and ASCII digits are accepted even where the numbering system is not latn (people type on the keyboard they have). One ambiguity is resolved in CLDR's favour — in a locale whose group separator is . (de-DE), "1.5" reads as 15, because that is what a . means when de-DE writes a number.

SpinBox is built on this; see the "Locale" section of cargo run -p spin-box.

Binary-size cost from the ICU additions is ~2 MB stripped (release build, with the CLDR subset baked in). No formatters cargo feature flag — these types are always on.

Cache lifetime

ICU formatter instances are cached per (LanguageIdentifier, options). The bundle-side path uses IntlLangMemoizer::with_try_get (per-bundle cache, lives as long as the bundle). The Signal-side path uses a thread-local RefCell<HashMap<...>> (lives for the thread's lifetime). First call per (lang, opts) constructs an ICU formatter via the Memoizable trait; subsequent calls reuse the cached instance. Construction is the expensive step (CLDR data lookup); the cached instances are immutable and format() is cheap.


Direction (LTR / RTL)

I18nManager::direction_signal() exposes a Signal<LayoutDirection> (LeftToRight | RightToLeft) computed via rtl_from_locale when the active locale changes. The window manager applies it to the widget tree before the locale-driven composite rebuild, so HStack lays out children Leading→Trailing → Trailing→Leading without any widget-side wiring.

The internationalization example flips between en-US / fr-FR / ar-SA to demonstrate this.


Hot reload

For translator workflows where editing a .ftl should immediately re-render the running app:

#![allow(unused)]
fn main() {
let config = I18nConfig::new()
    .compile_in(&[("en-US", &[include_str!("../locales/en-US.ftl")])])
    .runtime_override("fr-FR".parse().unwrap(), "/tmp/translation.ftl".into());
}

runtime_override(locale, path) registers a path. At startup the manager wires an FtlFileWatcher on it and calls manager.reload_from_path(locale, path) on every change. That rebuilds the bundle and bumps version_signal, which triggers every tr!-bound and Signal-side formatter to re-resolve.

Hot reload is for development only. Production apps ship compile_in-bundled .ftl and don't expose runtime overrides.

The CLI flag pattern from examples/internationalization:

cargo run -p internationalization -- \
    --translation-dev fr-FR=/tmp/fr.ftl \
    --translation-dev ar-SA=/tmp/ar.ftl

Point it at the directory, not one file

path is either a single .ftl file or a directory of them, and for a multi-file catalogue only the directory form is correct.

A locale's bundle is the merge of every resource registered for it (see Composing catalogues), so an app shipping main.ftl + tooltips.ftl + tags.ftl has one fr-FR bundle built from three files. reload_from_path replaces that bundle. Point the override at one of the three and saving it drops every key the other two defined. Those keys fall back to the source locale silently, so the translator watches most of the app revert to English with no error printed anywhere:

runtime_override("fr-FR", "locales/fr-FR/main.ftl")   # ✗ one file of three
  save main.ftl  →  fr-FR bundle = main.ftl alone
                    every tooltips.ftl / tags.ftl key now resolves via en-US

runtime_override("fr-FR", "locales/fr-FR")            # ✓ the directory
  save any of them  →  fr-FR bundle = all three, merged, as the binary ships it

Directory mode reads the .ftl files directly inside path. There is no recursion, so pointing at locales/ rather than locales/fr-FR/ finds nothing and errors instead of quietly loading a sibling locale's strings into the wrong bundle. Files are merged sorted by file name, because Fluent keeps the first definition of a key and read_dir order is unspecified: without the sort, a key defined in two files could resolve differently between two saves of the same unchanged directory.

Failure is atomic. Every file is parsed before the bundle is assembled, so one malformed file mid-edit leaves the previous bundle in place and returns ReloadError rather than installing a half-built catalogue that matches no build. A directory holding no .ftl at all is ReloadError::NoFtlFiles. Cross- file key collisions are logged and tolerated, exactly as build_bundle_from_resources tolerates them at startup. Hot reload's job is to reproduce what the shipped binary does, and refusing where the compiled build loads happily would strand the translator on a stale bundle.

The watcher follows the same split: a file target watches its parent directory (catching the write-temp-then-rename pattern editors use, which invalidates an inode watch on the file itself), while a directory target is watched as-is. Climbing to its parent would watch locales/ and wake every locale on any one locale's save. On a directory, only .ftl writes wake the sink, so the swap files and .ftl~ backups editors scatter alongside don't each cost a re-parse; the sink is then handed the directory, not the file that changed, which is what makes the rebuild read all of them.

Registering the same locale twice does not merge the two paths. Each change rebuilds that locale from whichever path fired, so pass the directory that holds both instead.


Switching locale at runtime

Any handler with an EventContext can flip the active locale:

#![allow(unused)]
fn main() {
Button::new(tr!(lang_french()))
    .on_activate_fn(|ctx| ctx.set_locale("fr-FR"))
}

EventContext::set_locale(impl Into<String>) defers a tree-level locale request; the framework parses the tag, calls manager.set_locale(lid), applies the resulting layout direction change (if any), and rebuilds composite widgets so tr! lookups captured at build time get re-evaluated.

Reactive consumers (anything bound via tr!.to_signal(), tr_signal!, NumberFormatter::format, TeksiloDateTimeFormatter::format) update without a rebuild — their underlying version_signal / locale_signal observers fire immediately. The composite rebuild exists so tr!(...) calls embedded inside build() pick up new text on the next layout pass.


Thread-local accessors

Every running thread that has called teksilo_i18n::thread_local::install(mgr) can reach the active manager via these crate-root functions:

FunctionReturns
current_locale()Option<Signal<LanguageIdentifier>>
current_direction()Option<Signal<LayoutDirection>>
current_version_signal()Option<Signal<u64>>
current_supported_locales()Option<Vec<LanguageIdentifier>>

install and clear are exposed under teksilo_i18n::thread_local::* for test harnesses; production apps shouldn't call them directly — TeksiloAppBuilder::i18n(...) does it for you.

with_active(|mgr| ...) -> Option<R> runs a closure with a borrow of the active manager if one is installed. Used internally by resolve_message and the formatter signal builders; rarely needed in app code.


compile_in_locales! declarative sugar

For multi-locale × multi-file projects, write the slice with the declarative macro instead of by hand:

#![allow(unused)]
fn main() {
use teksilo_i18n::compile_in_locales;

let cfg = I18nConfig::new()
    .compile_in(compile_in_locales!(
        base = "../locales/",
        locales = ["en-US", "fr-FR", "es-ES", "ar-SA"],
        files = ["main.ftl", "auth.ftl", "editor.ftl"],
    ));
}

Expands to:

#![allow(unused)]
fn main() {
&[
    ("en-US", &[
        include_str!("../locales/en-US/main.ftl"),
        include_str!("../locales/en-US/auth.ftl"),
        include_str!("../locales/en-US/editor.ftl"),
    ]),
    ("fr-FR", &[ /* …same files… */ ]),
    // …
]
}

Constraints:

  • Every locale × file combination must exist on disk — include_str! fails at compile time on a missing file.
  • base is relative to the source file that invokes the macro, not the crate root. For a binary crate with main.rs in src/ and locales at <crate>/locales/, the correct base is "../locales/".
  • If a locale ships a different subset of files, fall back to writing the explicit slice by hand — the sugar assumes uniform coverage.

Common pitfalls

Translation key not found at compile time. The macro reports "translation key foo not found in <path>" with a Levenshtein- based "did you mean" suggestion. Check the path it printed — if it's wrong, set TEKSILO_I18N_SOURCE_PATH (single file) or TEKSILO_I18N_SOURCE_DIR (directory) in the calling crate's environment.

tr!(héllo()) rejected. Fluent message ids are ASCII-only ([a-zA-Z][a-zA-Z0-9_-]*). The macro rejects non-ASCII segments upfront with a clearer message than "key not found".

{ NUMBER($v) } renders as {NUMBER()} placeholder. Means bundle.add_builtins() wasn't called on the bundle. Every bundle created via I18nManager (whether from from_config, reload_from_path, framework registration) goes through configure_bundle, which calls add_builtins. If you're seeing this, you constructed a FluentBundle manually outside the manager — don't.

tr_signal! resolver fires, but the displayed text doesn't update. The result Signal<String> is dropped. The macro attaches observers via attach_keepalive, so observer lifetimes are tied to the result signal. If you bind the result to a widget that's later removed, the observers detach automatically — that's correct behaviour, not a bug.

Hot reload turned most of the app back to English. The runtime_override is pointed at a single .ftl in a locale that ships several. Reloading one file replaces the locale's whole bundle, so every key the other files defined falls back to the source locale. Point the override at the directory, as described in Point it at the directory, not one file.

Signal<String> fallback when no manager is installed. Both LocalizedString::to_signal() and the formatter signals work without an installed manager; they emit a static or default-locale value once and never re-render. Useful for low-level widget tests that shouldn't pull in i18n state.

Calling format!("{}", ls) on a LocalizedString. Doesn't work — there's no Display impl, and adding one would make it ambiguous whether it resolves now or returns the resolver's debug form. Use ls.resolve_now() or ls.to_signal() instead.


Files reference

TopicPath
I18nConfig buildercrates/teksilo-i18n/src/config.rs
I18nManager + bundle wiringcrates/teksilo-i18n/src/manager.rs
LocalizedString + to_signalcrates/teksilo-i18n/src/localized_string.rs
Locale-aware formatting (Number/DateTime)crates/teksilo-i18n/src/format.rs
Hot-reload file watchercrates/teksilo-i18n/src/file_watcher.rs
Directory-override testscrates/teksilo-i18n/tests/runtime_override_dir.rs
Layout direction (RTL)crates/teksilo-i18n/src/direction.rs
Thread-local + crate-root accessorscrates/teksilo-i18n/src/thread_local.rs
tr! / tr_widget! / tr_signal! / tr_signal_widget!crates/teksilo-i18n-macros/src/lib.rs
compile_in_locales! declarative macrocrates/teksilo-i18n/src/lib.rs
End-to-end demoexamples/internationalization
Format integration testscrates/teksilo-i18n/tests/format_integration.rs
tr! integration testscrates/teksilo-i18n/tests/tr_macro.rs