Localisation design
The developer-facing design for localisation. Looking for the user guide (how to change your Locale, what changes)? See Localisation.
How Personal Ledger presents its interface in a Locale: which Messages the desktop and TUI show, and how numbers, dates and currency amounts are formatted. This page records the decisions reached on the Localisation of the Desktop and TUI UX Wayfinder map; each decision’s detail lives on its ticket, and the hard-to-reverse parts are in ADR-0021. The terms Locale, Message and Catalogue are defined in CONTEXT.md.
Status
Planned, not yet built. The shared crate, lib-locale, does not exist yet, and both bins still use hardcoded literals (about 1,700 candidate Message literals across bin-desktop and bin-tui). All the design decisions are made and the build is specced as ready-for-agent tickets listed on the map. Until the migration tickets land, don’t add hardcoded UI strings expecting lib-locale to exist, and leave literals as they are.
Locales
en-USis the source Locale: every Message is first written in it, and it is the only complete Catalogue. A missing Message there is an error.en-GBanden-AUare sparse override layers holding only the Messages that differ from the Locale they fall back to. The fallback chain is set explicitly in the crate, not left to negotiation:en-AUfalls back toen-GB, which falls back toen-US. Australian spelling matchesen-GBfar more thanen-US, soen-AUholds only what differs fromen-GB.en-XAis a pseudo-Locale used to find untranslated text and layout overflow. It is generated fromen-USat load time (accents and roughly a third extra length, with bracket delimiters), so no fourth Catalogue file exists. It is present in every build, never listed in Settings, and reachable only through thelocaleconfiguration. Its formatting falls back toen-US, because ICU4X has noen-XAdata.- Locales beyond these four, right-to-left scripts and non-Latin fonts are out of scope. A future real translation (for example
fr) must be a complete Catalogue, and a missing Message there is a warning rather than an error.
How the Locale is chosen
The Locale is Configuration, not a Preference. It is per-Client, never synced, needs no database read, and changes only by editing the configuration and restarting the Client. There is no in-app switch and no runtime Locale change to propagate.
lib-config resolves the requested Locale, from lowest to highest precedence:
- A
DEFAULT_LOCALEconstant,en-US. - The operating system’s locale, detected with
sys-locale. - The
localekey in the[Personal-Ledger]section, which followslib-config’s existing layers: config files, then thePERSONAL_LEDGER_PERSONAL_LEDGER__LOCALEenvironment variable, then the--locale <TAG>(no short flag) command-line flag.
The layers are read into locale: Option<String> (absent means unset) and resolved in a step after the build: the config value if present (source Config), else the detected system locale (source System), else DEFAULT_LOCALE (source Default). There is no system keyword: an absent key means the detected system locale stands. The value is validated as a BCP-47 tag with unic-langid internally and stored as a plain canonical-cased string, and the LocaleSource lets the Settings page show the effective Locale read-only with where it came from. A malformed value from a file, environment variable or flag is a startup error (InvalidLocale). A valid but unsupported tag passes through, because lib-config does not know the supported set. An unreadable system value (C, POSIX) is ignored quietly. The Sync Server skips detection and never reads the field.
Note that the documented double-underscore environment overrides do not currently work (Environment::with_prefix is missing .separator("__")), so a small bug ticket fixes them before the locale key is added.
lib-locale then negotiates that string against the supported set: an exact match wins, anything else (en-NZ, fr-FR, C, POSIX) becomes en-US, and en-XA is chosen only by an exact request. Each bin’s main calls Config::parse and then lib_locale::init, which sets the process-wide loader once. Settings has no control to change the Locale.
Formatting
The Locale owns number, date and currency formatting, replacing the earlier NumberFormat and DateFormat Preferences (ADR-0021). The two definitions that had already drifted apart, in lib-core and in bin-desktop, collapse into lib-locale.
- Numbers: the number-separator Preference is deleted, because
en-US,en-GBanden-AUall use1,234.56. - Dates: the date Preference becomes a nullable, synced date style (short, medium, long or ISO), persisted as
DateStyleinlib-core. No value means the Locale’s default, and ISO overrides the Locale for both display and typed input. The date style still changes live, because it only changes formatting output. - Typed date input: follows the Locale’s short form (read from the resolved ICU4X pattern), always also accepts ISO
YYYY-MM-DD, and acceptstoday,yesterdayandtomorrow. The accepted words come fromdate-word-*Messages, with the English words always accepted. Two-digit years are accepted and year-less input is opt-in per call site. ICU4X does not parse, so this is a small parser inlib-localeoverNaiveDate::from_ymd_opt. - Currency: the Unit decides what is shown (its code and fraction digits) and the Locale decides how (separators, symbol placement, disambiguation such as
A$for anen-USuser). Fiat Units go through the ICU4X currency formatter, wrapped and pinned becauseicu_experimentalis not semver-stable. Non-fiat Units (tickers, bitcoin) format the quantity as a decimal and place the Unit code through a Message. - Exactness: amounts stay
Money(BigDecimal), converted withto_plain_string()and never throughf64. Fluent’s ownNUMBER()andDATETIME()are not used, because in the Rust implementation they do no locale formatting.
Messages
- What is a Message: labels, headings, prose, validation and status text, command descriptions, argument placeholder words and example prose, hints, the labels of enumerated domain values, and user-reachable error text.
- What is not: command names and syntax (
accounts new,[into <acct>]), which are stable English ids typed by the user and dispatched on an id, never on display text; mock and seed data; element ids, paths, config keys and URLs; tracing, panic andexpecttext;sync-serveroutput; CLI--help; and temporarynot yet builtplaceholders, which are deleted as features land. - Domain vocabulary: each variant of
AccountType,TransactionStatus,CategoryTypes,UnitKindandBudgetPeriod, plus Flagged, has one Message with one canonical translation per Locale, shared by both bins. Stored tokens (as_str()) stay stable and are never displayed. ALabeltrait inlib-localeprovides them, so a call site readsaccount_type.label(), and each implementation is an exhaustivematch, so a new variant fails to compile until its Message exists. - Errors: libraries keep returning typed
thiserrorerrors with EnglishDisplayfor developers and logs. The bin maps each user-reachable variant (about 10 to 15) to a Message. Developer-facing wrapper errors are not Messages and are never spliced into a localised sentence througherr.to_string(). - Plurals: every count-plus-noun is a Fluent plural selector, even where English needs only one/other, and stems plus suffixes (
categor{y,ies}) are banned. - Glyphs: the label is the Message and the glyph is a separate argument, because glyph style is a Display Preference. The join is one Message with two variables.
- Casing and abbreviations: a Message is written once in natural sentence case. Upper-casing is done by the renderer through a Locale-aware helper. Abbreviations are not the default: the source Message uses the full word, and a
.shortvariant exists only where layout needs it. - Sentences: one Message per complete sentence or paragraph, never split for styling. Hard-wrapped literals are rejoined, and wrapping is the renderer’s job.
- Seeded rows: system-seeded rows that ship into synced data (such as
No institution) store a stable key and render the Message at display time, so Clients in different Locales each show their own.
Looking up a Message
Call sites use generated typed accessor functions with typed arguments, checked against en-US, not string ids and not a macro. A plural count is a typed integer and key tokens are typed arguments.
#![allow(unused)]
fn main() {
// bin-desktop
div().child(msg::accounts_delete_title(count))
// bin-tui
Line::from(msg::accounts_delete_title(count))
}
- Module:
lib_locale::msg::…for the shared layer, and each bin’s owncrate::msg::…for its bin-only Messages. Bin-only ids carrydesktop-ortui-prefixes, so the two modules never share a function name. - Ids: Fluent ids are kebab-case,
<area>-<thing>-<role>, named for their role rather than their English text (accounts-add-submit). Related Messages such as a dialog’s title, body and buttons use Fluent attributes on one id (accounts-delete.title). The generated accessor is the same name in snake_case. Shared vocabulary uses fixed families (account-kind-credit-card,transaction-status-open). - Loader: one process-wide loader, set once at startup, so views need no
cxor handle plumbing and lookups work in bothrenderanddraw. Tests get a scoped, thread-local override so parallel tests can use different Locales. - Arguments: only counts go in as numbers, for plural selectors. Money, dates and other formatted values arrive as strings from the formatting layer. Unicode isolating marks around placeables are switched off globally.
- Misses: a missing Message never panics. It resolves through the fallback chain to
en-US, and an id missing everywhere (possible only for a dynamic id) returns the id wrapped as⟦id⟧in debug builds and plain in release, with onetracing::warn!. - Styled and clickable spans: key tokens and nouns are normal string arguments passed as sentinels (a private-use code point pair carrying an index), and the formatted text is split so the caller styles each token. A translated styled span (the clickable
openin a sentence) uses a lightweight tag convention,<open>open</open>, parsed into segments with an optional tag. Both return segments with no UI types, each bin maps tags to its own styling or click handler, arguments are escaped so a value cannot forge a tag, and a test requires each Locale to use the same tags asen-US.
Catalogues and delivery
- Embedded at compile time. The Windows TUI is a single portable
.exeand the AppImage is a single file, all four Locales are shipped with the app, andbin-desktopalready embeds its icons withinclude_bytes!. Loading user-supplied Catalogues from disk is not planned. - Layers: a shared layer in
lib-locale(navigation, domain vocabulary, column labels, common dialogs, formatting Messages) plus a layer each bin owns for its own screens. The loader adds the layers as resources to one bundle per Locale, and each bin’sbuild.rsgenerates its own accessors with the same checks. - Files:
crates/libs/lib-locale/i18n/<locale>/<area>.ftlfor the shared layer, andcrates/bins/bin-desktop/i18n/<locale>/*.ftlandcrates/bins/bin-tui/i18n/<locale>/*.ftlfor each bin’s own. - Checks, run as ordinary
cargo test: every id in every Locale exists inen-US, variables and tags match,en-UShas no missing Message a bin references, and anen-XAsmoke test renders every Message without a panic or a literal id. Unused Messages are a warning from the generator, which emits its own report because the workspace allowsdead_code. - Adding a screen: add its Messages to
en-USonly.en-GBanden-AUinherit until they need to differ, anden-XAis automatic.
Crate structure
crates/libs/lib-locale/
build.rs generates msg/ accessors
i18n/<locale>/*.ftl the shared layer
src/
locale.rs supported set, negotiation, fallback chain
catalogue/ embedded layers, bundles, pseudo transform, loader, test override
msg/ generated shared accessors
rich.rs sentinel splitter and tag parser, no UI types
format/ number, date, currency, input (typed dates), casing
vocabulary.rs the Label trait for lib-core enums
error.rs
tests/ cross-Locale check, en-XA smoke test
lib-localedepends onlib-core, never the reverse, solib-corestays pure with no I/O and no ICU.lib-configdepends on neitherlib-localenor ICU4X, andlib-localedoes not depend onlib-config.bin-sync-servernever depends onlib-locale.- The generator is a small separate crate,
lib-locale-build, depending only onfluent-syntaxand used bylib-locale’s and each bin’sbuild.rs, because abuild.rscannot use its own crate’s dependencies and a build-dependency onlib-localewould compile Fluent and ICU4X for the host. The spike (research note) chose our own generator overfluent-typed, so the crate is created. - No
fluent-bundle,icuorunic-langidtype appears in any public signature, so a dependency can change without touching the bins. - Public surface:
init(&str) -> Locale,Locale, the generatedmsg::…accessors, the rich segment types,format_money(&Money, &Unit),format_date(NaiveDate, DateStyle),format_number,parse_date(&str, today), a Locale-awareupper, theLabeltrait, and awith_locale(...)test helper. - Dependencies to add:
fluent-bundle,fluent-langneg,fluent-pseudo(andfluent-syntax, inlib-locale-buildonly),unic-langid,icu(icu_decimal,icu_datetime,icu_casemap, and a pinnedicu_experimentalfor currency), andsys-localeinlib-config.
The name
The crate is lib-locale (crate lib_locale), chosen over lib-localisation and lib-i18n. It under-describes a crate that also owns Catalogues and formatting, so its rustdoc must state the whole scope up front, and gpui_component::locale() must be imported explicitly rather than glob-imported. Identifiers and Message ids use Australian spelling (Catalogue, colour); Message text follows its Locale, so en-US writes “color” and en-GB and en-AU override it as “colour”.
Interaction with the UI stacks
gpui-componentships its ownrust-i18n(English, Simplified and Traditional Chinese, and a partial Italian). The Locale is a process-wide static, so the desktop callsgpui_component::set_localeonce at startup with the mapped tag (anyen-*maps toen). Its calendar month and weekday names, the input context menu and dock strings cannot be overridden from outside, so they stay English or are avoided. ItsDialogbutton labels and the placeholders onSelect,DatePicker,ListandInputare overridden at the call site fromlib-localeMessages.gpuihas no Locale concept. Because the Locale never changes at runtime, no change signal orrefresh_windowsis needed.ratatuimeasures width in terminal cells withunicode-width. Accented Latin is one cell wide, but a longer Message is clipped, not wrapped, unless the widget wraps. Any width arithmetic must useUnicodeWidthStr::width, neverstr::len()orchars().count(), and fixed-width columns and one-line hints need generous constraints or explicit truncation soen-XApasses cleanly.
Open items
- Settled by the generator spike (note):
- We write our own generator over
fluent-syntax, notfluent-typed, which cannot express sparse override Locales, reports cross-Locale problems as warnings, and generates instance methods rather than free functions over a process-wide loader. Accessors takei64for counts and&strotherwise, and the kebab-case to snake_case conversion works, with a generator check for accessor-name collisions (an attributeaccounts-delete.titlecollides with a messageaccounts-delete-title). - A bin generates its own layer from
bin-*/i18n/with the same generator, and the runtime composes every layer into one bundle per Locale withadd_resource_overridingin fallback-chain order.fl!fromi18n-embed-flis not a fallback, because it validates ids only against its own crate’s assets. The bin build cannot see the shared layer’s terms and ids, so it must not fail on an unresolved reference, and cross-layer id collisions are caught by thedesktop-/tui-prefixes, a check atinitand a test. en-XAusesfluent_pseudo::transform_domwith markers off, because plaintransformmangles<tag>names and its markers bracket each text fragment; the generated outer[ … ]is added around the whole Message. The private-use sentinels survive the transform.- The chain
en-AU -> en-GB -> en-USworks with sparse Locales.
- We write our own generator over
- Measured on a
ratatuistand-in, for a stripped release binary: Fluent adds about 2.1 MB (1.4 MB with a size-tuned profile) and 11 s of clean build, and ICU4X with its default data adds about 1.3 MB and 24 s. Anicu4x-datagentrim toen-US,en-GBanden-AUcuts the ICU4X share to about 0.3 MB and saves about 12 s. The trim is a separate, later ticket, and must be regenerated when a new ICU4X marker is used. - Not yet scheduled: the per-screen string migration, the Preference migration (the number-separator column goes and
date_formatbecomes the nullable date style), the TUI palette’s move from dispatch-on-display-text to dispatch-on-id, the seeded-row keys, translation workflow, per-Locale command aliases, user-supplied Catalogues, and mapping Commonwealth variants (en-NZ,en-IN) toen-GB. - The cost figures come from a stand-in binary, not a real
bin-tuiorbin-desktopbuild, so re-measure oncelib-localeexists.
Research
The generator spike’s findings are in Localisation Message generator spike. The other findings behind these decisions are Markdown notes on throwaway research/localisation-* branches (they are not merged): the Fluent binding, formatting, gpui and ratatui behaviour, the hardcoded-text inventory, crate naming precedents, date input parsing, and the confirmation of Fluent against alternative message systems (which found no blocker: MessageFormat 2’s Rust runtime is not yet usable, and the gaps in Fluent are ones the shared crate would add anyway).