i18n: a locale per app, catalogs of messages, mirrored stacks

i18n::I18n is a gpui global holding the shown locale and one catalog of
messages per locale. text fills each {name} from its arguments; a
missing key, argument or catalog fails, and every catalog holds the
first one's keys. Locale writes numbers with its separators and dates
in CLDR's short numeric form; Hebrew replaces Arabic, whose Egyptian
digits Ely does not write. Direction::row mirrors a stack and
Direction::align sets text against the reading side. The gallery's
chapter 40 shows the messages in seven locales, every locale's number
and date, and a file list that mirrors under Hebrew.
This commit is contained in:
ZacharyZhang-NY committed 2026-09-27 18:59:45 -04:00
1 parent 17e3e19fc9
commit ae5f2334f5
9 files changed
+616 -4

No files matched your search

+106
View File
@@ -0,0 +1,106 @@
use gpui::Styled;
use jiff::{Timestamp, tz::TimeZone};
use crate::typography::format::{self, Separators};
/// Which way a line of text runs.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Direction {
Ltr,
Rtl,
}
impl Direction {
/// A flex row that starts at the reading side.
pub fn row<E: Styled>(self, element: E) -> E {
match self {
Direction::Ltr => element.flex().flex_row(),
Direction::Rtl => element.flex().flex_row_reverse(),
}
}
/// Text set against the reading side.
pub fn align<E: Styled>(self, element: E) -> E {
match self {
Direction::Ltr => element.text_left(),
Direction::Rtl => element.text_right(),
}
}
}
/// A language as it writes numbers, dates and lines.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct Locale {
pub tag: &'static str,
pub name: &'static str,
pub separators: Separators,
/// A numeric date, as `strftime` reads it.
pub date: &'static str,
pub direction: Direction,
}
const fn locale(
tag: &'static str,
name: &'static str,
(group, decimal): (char, char),
date: &'static str,
direction: Direction,
) -> Locale {
Locale {
tag,
name,
separators: Separators { group, decimal },
date,
direction,
}
}
/// The locales Ely formats for, with CLDR's short numeric dates.
pub const LOCALES: [Locale; 7] = [
locale(
"en-US",
"English (US)",
(',', '.'),
"%-m/%-d/%Y",
Direction::Ltr,
),
locale(
"en-GB",
"English (UK)",
(',', '.'),
"%d/%m/%Y",
Direction::Ltr,
),
locale("de-DE", "Deutsch", ('.', ','), "%d.%m.%Y", Direction::Ltr),
locale(
"fr-FR",
"Français",
('\u{202f}', ','),
"%d/%m/%Y",
Direction::Ltr,
),
locale("ja-JP", "日本語", (',', '.'), "%Y/%m/%d", Direction::Ltr),
locale("zh-CN", "中文", (',', '.'), "%Y/%-m/%-d", Direction::Ltr),
locale("he-IL", "עברית", (',', '.'), "%-d.%-m.%Y", Direction::Rtl),
];
impl Locale {
/// The locale with this tag; fails on one Ely does not list.
pub fn of(tag: &str) -> Locale {
*LOCALES
.iter()
.find(|locale| locale.tag == tag)
.unwrap_or_else(|| panic!("no locale {tag:?}"))
}
/// `value` with this locale's separators.
pub fn number(self, value: f64, decimals: usize) -> String {
format::number(value, decimals, self.separators)
}
/// The day `at` falls on in `zone`, written as this locale writes dates.
pub fn date(self, at: Timestamp, zone: &TimeZone) -> String {
format::datetime(at, zone, self.date)
.unwrap_or_else(|error| panic!("{}'s date pattern: {error}", self.tag))
}
}
+108
View File
@@ -0,0 +1,108 @@
mod locale;
#[cfg(test)]
mod tests;
use std::collections::HashMap;
use gpui::{App, Global, SharedString};
pub use locale::{Direction, LOCALES, Locale};
type Catalog = HashMap<&'static str, &'static str>;
/// The locale shown and a catalog of messages per locale; every catalog holds the first one's keys.
pub struct I18n {
locale: Locale,
catalogs: Vec<(&'static str, Catalog)>,
}
impl Global for I18n {}
impl I18n {
pub fn new(locale: &str) -> Self {
Self {
locale: Locale::of(locale),
catalogs: Vec::new(),
}
}
/// Adds a locale's messages; fails on a key the first catalog lacks or holds alone.
pub fn catalog(mut self, tag: &str, messages: &[(&'static str, &'static str)]) -> Self {
let locale = Locale::of(tag);
let catalog: Catalog = messages.iter().copied().collect();
assert_eq!(catalog.len(), messages.len(), "{tag} repeats a key");
assert!(
self.catalogs.iter().all(|(other, _)| *other != locale.tag),
"{tag} has a catalog"
);
if let Some((first, source)) = self.catalogs.first() {
let mut missing: Vec<_> = source
.keys()
.filter(|key| !catalog.contains_key(*key))
.collect();
let mut extra: Vec<_> = catalog
.keys()
.filter(|key| !source.contains_key(*key))
.collect();
missing.sort();
extra.sort();
assert!(missing.is_empty(), "{tag} misses {missing:?} from {first}");
assert!(extra.is_empty(), "{tag} has {extra:?}, which {first} lacks");
}
self.catalogs.push((locale.tag, catalog));
self
}
pub fn locale(&self) -> Locale {
self.locale
}
/// Shows another locale in every window; fails on one without a catalog.
pub fn set_locale(tag: &str, cx: &mut App) {
let locale = Locale::of(tag);
let i18n = cx.global_mut::<I18n>();
assert!(
i18n.catalogs.iter().any(|(tag, _)| *tag == locale.tag),
"no catalog for {}",
locale.tag
);
log::info!("i18n: locale {} to {}", i18n.locale.tag, locale.tag);
i18n.locale = locale;
cx.refresh_windows();
}
/// The shown locale's message for `key`, each `{name}` filled from `args`; fails on a missing key or argument.
pub fn text(&self, key: &str, args: &[(&str, &str)]) -> SharedString {
let (_, catalog) = self
.catalogs
.iter()
.find(|(tag, _)| *tag == self.locale.tag)
.unwrap_or_else(|| panic!("no catalog for {}", self.locale.tag));
let message = catalog
.get(key)
.unwrap_or_else(|| panic!("no message {key:?} in {}", self.locale.tag));
fill(message, args)
.unwrap_or_else(|error| panic!("{key:?} in {}: {error}", self.locale.tag))
.into()
}
}
/// `message` with each `{name}` replaced by its argument.
fn fill(message: &str, args: &[(&str, &str)]) -> Result<String, String> {
let mut out = String::with_capacity(message.len());
let mut rest = message;
while let Some(open) = rest.find('{') {
out.push_str(&rest[..open]);
let after = &rest[open + 1..];
let close = after.find('}').ok_or("a { without }")?;
let name = &after[..close];
let (_, value) = args
.iter()
.find(|(arg, _)| *arg == name)
.ok_or_else(|| format!("no argument {name:?}"))?;
out.push_str(value);
rest = &after[close + 1..];
}
out.push_str(rest);
Ok(out)
}
+175
View File
@@ -0,0 +1,175 @@
use jiff::{Timestamp, tz::TimeZone};
use super::{Direction, I18n, LOCALES, Locale, fill};
fn i18n() -> I18n {
I18n::new("en-US")
.catalog(
"en-US",
&[("files", "{count} files in {folder}"), ("hi", "Hello")],
)
.catalog(
"de-DE",
&[("files", "{count} Dateien in {folder}"), ("hi", "Hallo")],
)
}
/// Each `{name}` takes its argument, in the order the message puts them.
#[test]
fn a_message_fills_its_arguments() {
let args = [("folder", "Docs"), ("count", "1,204")];
assert_eq!(i18n().text("files", &args), "1,204 files in Docs");
assert_eq!(i18n().text("hi", &[]), "Hello");
assert_eq!(fill("{a}{a}", &[("a", "x")]), Ok("xx".into()));
assert_eq!(fill("{a", &[("a", "x")]), Err("a { without }".into()));
assert_eq!(fill("{b}", &[("a", "x")]), Err("no argument \"b\"".into()));
}
#[test]
#[should_panic(expected = "\"files\" in en-US: no argument \"folder\"")]
fn a_missing_argument_fails() {
i18n().text("files", &[("count", "3")]);
}
#[test]
#[should_panic(expected = "no message \"bye\" in en-US")]
fn a_missing_key_fails() {
i18n().text("bye", &[]);
}
#[test]
#[should_panic(expected = "de-DE misses [\"hi\"] from en-US")]
fn a_catalog_missing_a_key_fails() {
I18n::new("en-US")
.catalog("en-US", &[("hi", "Hello"), ("files", "{count} files")])
.catalog("de-DE", &[("files", "{count} Dateien")]);
}
#[test]
#[should_panic(expected = "de-DE has [\"bye\"], which en-US lacks")]
fn a_catalog_with_a_stray_key_fails() {
I18n::new("en-US")
.catalog("en-US", &[("hi", "Hello")])
.catalog("de-DE", &[("hi", "Hallo"), ("bye", "Tschüss")]);
}
#[test]
#[should_panic(expected = "no locale \"xx-XX\"")]
fn an_unlisted_locale_fails() {
Locale::of("xx-XX");
}
/// Separators and the date's order follow each locale.
#[test]
fn locales_write_their_own_numbers_and_dates() {
let at = Timestamp::from_second(1_788_220_800).unwrap();
let cases: Vec<_> = LOCALES
.iter()
.map(|locale| {
(
locale.tag,
locale.number(-1234567.891, 2),
locale.date(at, &TimeZone::UTC),
)
})
.collect();
assert_eq!(
cases,
[
("en-US", "−1,234,567.89".into(), "9/1/2026".into()),
("en-GB", "−1,234,567.89".into(), "01/09/2026".into()),
("de-DE", "−1.234.567,89".into(), "01.09.2026".into()),
(
"fr-FR",
"−1\u{202f}234\u{202f}567,89".into(),
"01/09/2026".into()
),
("ja-JP", "−1,234,567.89".into(), "2026/09/01".into()),
("zh-CN", "−1,234,567.89".into(), "2026/9/1".into()),
("he-IL", "−1,234,567.89".into(), "1.9.2026".into()),
]
);
let rtl: Vec<_> = LOCALES
.iter()
.filter(|locale| locale.direction == Direction::Rtl)
.collect();
assert_eq!(rtl, [&Locale::of("he-IL")]);
}
#[cfg(feature = "test-support")]
mod shown {
use gpui::{
Context, InteractiveElement, IntoElement, ParentElement, Render, Styled, TestAppContext,
Window, div, px,
};
use super::super::{Direction, I18n};
/// A row of two marks, laid out the way `I18n`'s locale reads.
struct Row;
impl Render for Row {
fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let i18n = cx.global::<I18n>();
let direction = i18n.locale().direction;
direction
.row(div().w(px(280.0)).justify_between())
.child(
div()
.debug_selector(|| "first".into())
.w(px(40.0))
.child(i18n.text("hi", &[])),
)
.child(div().debug_selector(|| "second".into()).w(px(40.0)))
}
}
/// A new locale reaches every window, and a right-to-left one starts the row at the right.
#[gpui::test]
fn a_right_to_left_locale_mirrors_the_row(cx: &mut TestAppContext) {
cx.update(|cx| {
cx.set_global(
I18n::new("en-US")
.catalog("en-US", &[("hi", "Hi")])
.catalog("he-IL", &[("hi", "שלום")]),
)
});
let (_, cx) = cx.add_window_view(|_, _| Row);
cx.run_until_parked();
let first = cx.debug_bounds("first").expect("the first mark");
let second = cx.debug_bounds("second").expect("the second mark");
assert!(first.left() < second.left(), "left to right in en-US");
cx.update(|_, cx| I18n::set_locale("he-IL", cx));
cx.run_until_parked();
assert_eq!(cx.read(|cx| cx.global::<I18n>().text("hi", &[])), "שלום");
let first = cx.debug_bounds("first").expect("the first mark");
let second = cx.debug_bounds("second").expect("the second mark");
assert!(
first.left() > second.left(),
"right to left in he-IL: {first:?} {second:?}"
);
}
#[gpui::test]
#[should_panic(expected = "no catalog for de-DE")]
fn a_locale_without_a_catalog_fails(cx: &mut TestAppContext) {
cx.update(|cx| {
cx.set_global(I18n::new("en-US").catalog("en-US", &[("hi", "Hi")]));
I18n::set_locale("de-DE", cx);
});
}
#[test]
fn text_sits_against_the_reading_side() {
let side = |direction: Direction| {
direction
.align(div())
.style()
.text
.as_ref()
.and_then(|text| text.text_align)
};
assert_eq!(side(Direction::Ltr), Some(gpui::TextAlign::Left));
assert_eq!(side(Direction::Rtl), Some(gpui::TextAlign::Right));
}
}
+1
View File
@@ -20,6 +20,7 @@ pub mod finance;
pub mod forms;
pub mod generative;
pub mod git;
pub mod i18n;
pub mod interaction;
pub mod layout;
pub mod lists;