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 resultingI18nManageron the thread-local; once installed, everytr!/tr_signal!/current_locale()/NumberFormattercall in the same thread routes through it.compile_inregisters.ftlstrings (typically pulled in viainclude_str!) so they're embedded in the binary. Usecompile_in_locales!for the multi-locale × multi-file case.- The framework's own widget strings come from
teksilo::widgets::framework_locales()— this is what givesMenuBar,Dialog, accessibility labels, keystroke names, etc. their localized text. Apps almost always want this. teksilo::prelude::*re-exportstr/tr_widget/LocalizedString/I18nConfig/LanguageIdentifier(gated on thei18ncargo feature). The reactive macros (tr_signal,tr_signal_widget) and the formatter types (NumberFormatter,TeksiloDateTimeFormatter,TeksiloDateTime,NumberStyle,DateStyle,TimeStyle) are reachable through theteksilo::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.
| Method | Purpose |
|---|---|
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_resourcekeeps 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-FRfrom an extension does not fold those strings into the application'sen-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):
-
config.user_locale— explicit app choice (e.g. read fromSettingsStore). Honored only if it's insupported_locales. -
OS locale, if
auto_detect_os_localeis on (the default). The writer's whole preferred-language chain is read from thesys-localecrate 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:- exact — every subtag agrees;
- same language and script, any region — an OS-reported
en-GBmatches a supporteden-US, andfr,fr-CA,fr-BEorfr-CHall match a supportedfr-FR; - same language, script named on only one side —
zh-Hansmatches a supportedzh-Hans-CN. A script named on both sides and disagreeing is never bridged, sozh-Hanscan not land onzh-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. -
config.fallback_locale— returned unconditionally if neither of the above produced a hit. Defaults toen-USif not set; not validated againstsupported_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
.ftlin this order: directory atTEKSILO_I18N_SOURCE_DIR(env var, dir of.ftlfiles) → single file atTEKSILO_I18N_SOURCE_PATH(env var, one.ftlfile) → 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-fileauth.ftl/editor.ftl/... projects work without configuration. - It checks the message key exists, and that every named arg matches
a
$variabledeclared in the message. Missing key, missing arg, unknown arg — all producecompile_error!at the call site, with Levenshtein-based "did you mean" hints for typos. - Every parsed
.ftlfile is registered as a build dependency via an emittedinclude_bytes!, so cargo rebuilds the calling crate when the source file changes.
Key path → Fluent key mapping:
tr!(count_items())→ fluent keycount-items(single underscore inside a segment becomes a dash).tr!(auth::login_title())→ fluent keyauth__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'sversion_signal, which fires on locale changes and.ftlhot 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.
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:
| Method | Default | Purpose |
|---|---|---|
.style(NumberStyle) | Decimal | Set 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) | none | Min zero-pads; max rounds half-to-even. |
.use_grouping(bool) | true | Toggle locale grouping separators. |
TeksiloDateTimeFormatter builder methods:
| Method | Default | Purpose |
|---|---|---|
.date_style(DateStyle) | Medium if neither set | Long/Medium/Short. |
.time_style(TimeStyle) | none | Long/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
CurrencyFormatterwith 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(...).JPYrenders"¥1,235", not"¥1,234.50". This is ECMA-402 behaviour. .use_grouping(false)does not reach currency: theCurrencyFormatterconstructors build their ownDecimalFormatterand expose no seam to pass ours. ACurrencystyle with no code, or a code ICU rejects, falls back to plain decimal rather than rendering a wrong currency.
- ICU applies the currency's CLDR fraction precision, which
overrides
- 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:
| Function | Returns |
|---|---|
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 × filecombination must exist on disk —include_str!fails at compile time on a missing file. baseis relative to the source file that invokes the macro, not the crate root. For a binary crate withmain.rsinsrc/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
| Topic | Path |
|---|---|
I18nConfig builder | crates/teksilo-i18n/src/config.rs |
I18nManager + bundle wiring | crates/teksilo-i18n/src/manager.rs |
LocalizedString + to_signal | crates/teksilo-i18n/src/localized_string.rs |
| Locale-aware formatting (Number/DateTime) | crates/teksilo-i18n/src/format.rs |
| Hot-reload file watcher | crates/teksilo-i18n/src/file_watcher.rs |
| Directory-override tests | crates/teksilo-i18n/tests/runtime_override_dir.rs |
| Layout direction (RTL) | crates/teksilo-i18n/src/direction.rs |
| Thread-local + crate-root accessors | crates/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 macro | crates/teksilo-i18n/src/lib.rs |
| End-to-end demo | examples/internationalization |
| Format integration tests | crates/teksilo-i18n/tests/format_integration.rs |
tr! integration tests | crates/teksilo-i18n/tests/tr_macro.rs |