Skip to content

Build an app

Localization ​

A Tessera app keeps its user-facing text in Fluent catalogs, one set of files per language, and looks messages up where they are displayed. The current language is reactive state owned by the app entry, so switching it rebuilds the components that read translated text while the app keeps running.

Here is what a lookup looks like in a component:

rust
use tessera_components::text::text;
use tessera_ui::tessera;
use crate::res;

#[tessera]
fn page_heading() {
    text(res::I18N.text("notes-title"));
}

res::I18N comes from the generated resource module. The rest of this page sets it up and registers it. As with other Material components, the example belongs under the app's material_theme() ancestor.

Write the catalogs ​

Add an [i18n] table to tessera-config.toml:

toml
[i18n]
dir = "locales"
fallback = "en"

dir is relative to the package root and defaults to locales. fallback is required: it names the language used when a message is missing, and it is also the generated catalog's default language. That language's catalog must exist.

Under that directory, give each language its own folder and group messages by feature:

text
locales/
├── en/
│   ├── notes.ftl
│   └── settings.ftl
└── zh-CN/
    ├── notes.ftl
    └── settings.ftl

All the files in one language folder form a single catalog. File names only organize the source; they are not part of the message ID. That means IDs must be unique across a language's files, and every translation must use the same IDs and argument names.

For example, locales/en/notes.ftl contains:

ftl
notes-title = Notes
notes-search = Search notes
notes-greeting = Hello, { $name }
notes-selected = { $count ->
    [one] One note selected
   *[other] { $count } notes selected
}

And locales/zh-CN/notes.ftl contains:

ftl
notes-title = 笔记
notes-search = 搜索笔记
notes-greeting = 你好,{ $name }
notes-selected = 已选择 { $count } 条笔记

Give messages stable IDs that describe their meaning, such as notes-title. Write each sentence whole in one Fluent message, so translators can move arguments around and pick the plural and grammatical forms their language needs; the Chinese file above uses a single form because Chinese nouns do not inflect for number.

Generate the resource module ​

The app needs tessera-i18n as a normal dependency and tessera-build as a build dependency. The scaffold's build.rs already calls the resource build:

rust
fn main() {
    tessera_build::build().expect("Failed to build Tessera resources");
}

The build reads the configuration, validates the catalogs and embeds the Fluent sources in generated bindings. Catalog files are tracked as build inputs, so editing a .ftl file triggers a rebuild, and the catalogs ship inside the executable.

src/res.rs exposes those bindings to the rest of the crate:

rust
#[allow(missing_docs, clippy::all)]
mod generated {
    include!(concat!(env!("OUT_DIR"), "/tessera_assets.rs"));
}

pub use generated::*;

The generated file holds the app's other resources too, and the build rewrites it each time, so make changes in the catalogs and tessera-config.toml. Resources covers the rest, and Project structure shows where these files sit.

Register the catalog ​

Register res::i18n() on the application entry, next to ComponentsPackage:

rust
mod app;
pub mod res;

use tessera_ui::EntryPoint;

#[tessera_ui::entry]
pub fn run() -> EntryPoint {
    EntryPoint::new(app::app)
        .config(res::application())
        .package(tessera_components::ComponentsPackage)
        .package(res::i18n())
}

Keep any target-specific configuration the scaffold added. The catalog package owns the entry's language state. Without it, res::I18N can still resolve messages in its default language, but I18n::current() has no controller to return and panics.

Look up messages ​

text resolves a message that has no arguments. format takes named arguments as an array, or any other iterator, of FluentValue pairs:

rust
use tessera_components::{column::column, text::text, text_field::text_field};
use tessera_i18n::FluentValue;
use tessera_ui::tessera;
use crate::res;

#[tessera]
fn notes_summary(count: u32) {
    column().children(move || {
        let greeting = res::I18N.format(
            "notes-greeting",
            [("name", FluentValue::from("Ada"))],
        );
        text(greeting);
        text(res::I18N.format(
            "notes-selected",
            [("count", FluentValue::from(count))],
        ));
        text_field().label(res::I18N.text("notes-search"));
    });
}

Pass numbers as numeric FluentValues whenever Fluent has to choose a plural form. Components take finished strings, so resolve the message, including its interpolated values, before you pass it in.

Lookups read the entry's language state, which is why they belong in the component body: resolved during the build, they make a language change rebuild exactly the components that read them. Keep message IDs or the underlying data in state and resolve the text in the body; a resolved translation stored in remember keeps its old language after a switch.

A Fluent message can carry attributes, and you can look one up as message-id.attribute. This is a tidy way to keep a control's name and its accessibility description side by side in the catalog:

ftl
notes-delete = Delete note
    .description = Moves the note to the trash
rust
use tessera_components::{icon_button::icon_button, material_icons::filled};
use tessera_ui::tessera;
use crate::res;

#[tessera]
fn delete_note_button() {
    icon_button()
        .icon(filled::DELETE_SVG)
        .accessibility_label(res::I18N.text("notes-delete"))
        .accessibility_description(res::I18N.text("notes-delete.description"))
        .on_click(|| {});
}

Accessibility labels are user-facing text: translate them with the same catalog as the visible copy. See Accessibility.

Change the language ​

Get the current entry's controller from a UI action and set the new language:

rust
use tessera_i18n::{I18n, langid};

I18n::current().set_locale(langid!("zh-CN"));

I18n::current().locale() returns the requested language; individual messages may still come from the fallback. locales() lists the languages the app catalog provides, which is handy for a language picker. The generated package starts in the configured fallback language. To start in another one, register res::i18n().locale(langid!("zh-CN")) instead.

set_locale changes in-memory state owned by the entry. If the user's choice must survive a restart, store it yourself and apply it at startup. Base route keys, item identities and user-entered content on stable values, separate from translated text, so they refer to the same things in every language.

Fallback and missing messages ​

The generated loader resolves each message separately: it tries the catalogs that match the requested language first, then the configured fallback. An incomplete translation therefore shows fallback text for the missing messages while the requested language stays the same. Keep the fallback catalog complete and check every language you ship.

If no catalog can resolve a message, text and format return its ID. A missing argument or another formatting error has the same result. When you see an ID on screen, check the message and its arguments.

Translate built-in component strings ​

Components that show their own text, such as the copy and paste actions of a text field, use message IDs that start with md3-. The component catalog ships English and Simplified Chinese (zh-CN) and follows the entry's language. Application messages take precedence over component messages, including the application's fallback messages.

To change a built-in string, add the same ID to an application catalog, for example in locales/en/controls.ftl:

ftl
md3-copy = Copy text
md3-paste = Paste text

Add the matching translations in your other languages too. When you override a message that takes arguments, keep its argument names and meanings. The catalogs in crates/tessera-components/locales/ in the Tessera repository define the IDs and arguments; take them from there.

Check each language in tests ​

A headless test can start the real entry, switch the language and check what the app publishes. Because it registers the same packages as the app, it catches missing registrations as well as missing messages. This one assumes the app shows the delete button from the previous section:

rust
use std::time::Duration;

use tessera_i18n::{I18n, langid};
use tessera_ui::{HeadlessConfig, accesskit::Role};

#[test]
fn toolbar_is_translated() {
    let mut renderer = crate::run()
        .into_headless(HeadlessConfig {
            width: 400,
            height: 300,
            scale_factor: 1.0,
            frame_time: Duration::from_millis(16),
            ..Default::default()
        })
        .expect("headless renderer");

    renderer.run(|| I18n::current().set_locale(langid!("zh-CN")));
    renderer.render_frames(2);

    let tree = renderer.inspect_accessibility().expect("accessibility tree");
    assert!(tree.find("删除笔记", Role::Button).is_some());
}

This needs the headless and testing features and a GPU adapter; Testing explains the setup.

See the localization API reference for catalogs and the language controller, and Entry point and windows for entry packages.