Translation and formatting¶
Lumen translates an app in four steps: mark the strings you want translated,
extract them into a catalogue, translate the catalogue, and run. Catalogues are
Fluent .ftl files in a locale/ directory
beside your markup. Numbers, dates and money are not in a catalogue; they are
formatted from the same locale.
1. Mark the strings¶
In markup, add translatable with a catalogue key. Keep the source-language
text in text; it is what shows until a translation exists:
<label translatable="app-title" text="Weather"/>
<button id="refresh" translatable="action-refresh" text="Refresh"/>
translatable works on any element that displays a string.
One key covers everything an element shows. The text comes from the message
itself; a placeholder and an alt come from the Fluent attributes
.placeholder and .alt on that same message:
<input id="q" translatable="search" placeholder="Search the catalogue"/>
<image src="logo.png" translatable="logo" alt="The Lumen logo"/>
Only a string you wrote in the markup is translated: a .placeholder entry
for an element with no placeholder attribute does nothing. A <tooltip> is
an element with a string of its own, so it takes its own key rather than an
attribute on the element it wraps:
<tooltip translatable="save-tip" text="Save the file">
<button id="save" translatable="save" text="Save"/>
</tooltip>
In a script, wrap the string in t():
tr() is the same function under the Qt spelling. In candela both live in the
lumen namespace: lumen::t("status-loading").
2. Extract¶
This walks the app directory, reads every .lmn, .rhai, .lua, and .cdl
file, and writes the keys it finds to the catalogue for the app's source
language: [app] fallback_locale in lumen.toml, else locale/en-US.ftl.
Pass --lang to target a different file:
Each new key is appended with a marker and its own key as the placeholder value:
An element's other strings arrive as attributes under the same message. An element that shows no text of its own gets a message with no value:
Extraction is safe to re-run. Existing entries are copied through untouched, so your translations survive; a message that gains a string later gains only the new attribute line. Keys you delete from your source stay in the catalogue until you remove them yourself.
3. Translate¶
One file per locale, named by its BCP-47 tag, directly inside locale/:
locale/en-US.ftllocale/de-DE.ftllocale/ja-JP.ftl
Fill in the values:
Fluent's selector syntax handles plurals inside the catalogue:
Selectors that take a variable, like the one above, are filled in by Rust SDK
apps. A markup app's t("items") passes no variables, so the placeable stays
unresolved; write separate keys for the cases you need.
4. Run¶
The locale the app starts in is, in order:
[app] localeinlumen.toml- the desktop's locale
en-US
Both values must be valid BCP-47 tags; a malformed one fails at startup.
When a key is missing from the active locale, Lumen falls back to
fallback_locale, then to the string the element was written with, then to the
key itself. fallback_locale is the language your source strings are written
in, and defaults to en-US; an app authored in German sets it to de-DE so a
miss reads German rather than probing an en-US catalogue it never ships.
Setting it to the locale the app is running in leaves nothing to fall through
to, so a miss goes straight to the authored text.
Fallback is per message, so a partly translated catalogue works: translated
messages appear translated, the rest read in the fallback language. The key
only stands in for an element's text, and only when the element has no other
translated string: an <input> marked for its placeholder shows an empty field
rather than the key.
Naming a locale with no catalogue file is not an error. Every message falls back, and the app runs in the text its source was written with.
On the web, translatable is resolved when the site is emitted, so a page
arrives in its language with nothing running. A build writes one document tree
per locale; under render = "ssr" it writes none and each request is rendered
in the locale it asks for. See the web target and
rendering on a server.
The tree's catalogue travels with the site, and the browser runtime loads it
for the locale the document was emitted in. So everything that appears after
the page opens reads in that language too: a row the runtime builds for a list
a script filled, an element a component fills, and whatever a script's t()
returns. A fallback_locale other than en-US applies to what the build
resolved, not to what the runtime builds afterwards: in the browser a page
reads its own catalogue and the en-US one, so a key missing from both reads
as the text it was authored with.
Changing the locale while the app runs¶
set_locale(tag) switches the running app to another locale, and locale()
answers the tag it is in. A language menu is those two calls:
fn pick_de() { set_locale("de-DE"); }
fn on_start() {
on("click", "de", "pick_de");
print(locale()); // "en-US" before anything switched it
}
In candela both live in the lumen namespace, like t:
lumen::set_locale("de-DE").
The switch takes effect on the click. Every string the markup marked is
resolved again from the new catalogue: an element's text, a text entry's
placeholder, a tooltip's body. Everything a format writes is rendered again
for the new locale, so a price reads 1,234.50 in English and 1.234,50 in
German. The base writing direction moves too, so picking Arabic mirrors the
tree that is already on screen and picking English unmirrors it.
Two things do not move. A caption a composed widget builds for itself is shown as written, the same limit as at startup. Anything built once outside the tree stays as it was built: a native menu, a tray tooltip, a window title a script set.
An element marked translatable shows the catalogue's string, including one
a script had replaced with set_text. The element declared that its text
comes from the catalogue, and a switch is the catalogue speaking again. Set
text on an element that names no key when you want a script to own it.
Naming a locale with no catalogue is not an error here either: every message falls back to the text the author wrote.
That last rule is what a browser page runs into. A page carries the catalogue
it was built for and the fallback one, so set_locale moves between those two
and any other tag leaves every message reading as authored. Nothing is fetched.
In a browser format still leaves a value as it stands, before a switch and
after it.
Formatting numbers, dates, and money¶
A translated string is only half of speaking a locale. 12,345.67 in English
is 12.345,67 in German, Jun 15, 2024 is 15.06.2024, and a euro amount
leads with the symbol in one and trails it in the other. Lumen writes all of
those from the app's locale, so one source file reads correctly in every
language you ship.
In markup, add format with a spec:
<label format="currency:EUR" text="1234.5"/>
<label format="date" text="2024-06-15"/>
<label format="number" bind-text="count"/>
The spec applies to whatever text the element ends up showing: an authored
text, or every value a bind-text signal writes.
The specs, and what each expects its text to be:
| Spec | Text | de-DE | en-US |
|---|---|---|---|
number |
a decimal number | 12.345,678 |
12,345.678 |
currency:<code> |
a decimal amount, <code> an ISO-4217 code |
1.234,50 then the euro sign |
the euro sign then 1,234.50 |
date |
YYYY-MM-DD, time optional |
15.06.2024 |
Jun 15, 2024 |
time |
YYYY-MM-DDTHH:MM[:SS] |
09:30:00 |
9:30:00 AM |
datetime |
YYYY-MM-DDTHH:MM[:SS] |
15.06.2024, 09:30:00 |
Jun 15, 2024, 9:30:00 AM |
relative |
whole seconds from now, past negative | vor 2 Stunden |
2 hours ago |
The currency symbol and the exact wording come from CLDR, so every locale Unicode publishes data for is covered, and each currency rounds to its own number of fraction digits: two for EUR and USD, none for JPY.
A date may be written as an RFC-3339 timestamp, which is what an API usually
returns. A fractional-seconds part and a trailing zone (Z, +02:00) are
read and dropped; the date and time are shown exactly as written, and Lumen
converts nothing between time zones.
In a script, one call per kind:
set_text("price", format_currency(1234.5, "EUR"));
set_text("when", format_date("2024-06-15"));
set_text("count", format_number(12345.678));
set_text("ago", format_relative(-7200));
format_time and format_datetime complete the set. In candela they live in
the lumen namespace, like t: lumen::format_number(12345.678).
A spec Lumen does not know, or text that is not what the spec expects, leaves
the text as it was. format="number" on not a number shows not a number,
the way an untranslated key shows itself: a typo in a spec, or data that is
briefly the wrong shape, does not blank an element. That also means format
on a translatable element does nothing useful, since a translated sentence
is not a number or a date.
While developing¶
lumenc run watches the locale/ directory. Editing a .ftl file, adding one,
or deleting one reloads the app with the new strings.
Right-to-left text¶
The base direction follows the app's locale. An app running in Arabic, Hebrew, Persian, or another right-to-left language starts mirrored with nothing in its markup saying so, on the desktop and on the web alike.
Set dir where you want something else:
dir accepts ltr, rtl, and auto, and it overrides the locale for the
element it is on and everything under it, so you can pin the whole page or
flip a single subtree.
lang tags a subtree's language for text shaping:
A value filled into a translated string is wrapped in Unicode isolation marks before it lands in the sentence, so an Arabic sentence holding a Latin name, or an English one holding an Arabic name, keeps its parts in the order the translator wrote them. The marks are invisible and apply to every locale and every target; there is nothing to turn on.
Limits today¶
- A caption a composed widget builds for itself is shown as written: a
<dropdown>placeholder, an<option>label, the placeholder a<date-picker>or<time-picker>fills in, and the labels of<menu>and<menuitem>carry no key of their own. - Formatting is reachable from markup and scripts. The Rust SDK has no formatting call.
- A script's
t()andformat_*calls return what they were given when a page is rendered on a server or prerendered by a build. Markuptranslatableandformatare resolved in both. - In a browser,
formatand theformat_*builtins leave a value as it stands. The catalogue travels with the site; the locale formatters do not. A value written into the document while the site was built is formatted.
The lumenc i18n flags are listed in the CLI reference,
and [app] locale in the lumen.toml reference.