From ae5f2334f5a7b8a0afdceae1d317f16fa1f13324 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=B7=E7=94=B5=E8=8A=BD=E8=A1=A3?= Date: Sun, 27 Sep 2026 18:59:45 -0400 Subject: [PATCH] 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. --- AGENTS.md | 1 + examples/gallery/main.rs | 1 + examples/gallery/pages/i18n/mod.rs | 217 +++++++++++++++++++++++++++++ examples/gallery/pages/mod.rs | 3 + src/i18n/locale.rs | 106 ++++++++++++++ src/i18n/mod.rs | 108 ++++++++++++++ src/i18n/tests.rs | 175 +++++++++++++++++++++++ src/lib.rs | 1 + tasks/ch31-43.md | 8 +- 9 files changed, 616 insertions(+), 4 deletions(-) create mode 100644 examples/gallery/pages/i18n/mod.rs create mode 100644 src/i18n/locale.rs create mode 100644 src/i18n/mod.rs create mode 100644 src/i18n/tests.rs diff --git a/AGENTS.md b/AGENTS.md index 1d76fce..aa173a7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -190,6 +190,7 @@ Ely GPUI Component. A component library for GPUI, in light and dark. - A pane the window's blur shows through is `shell::Vibrancy`, filled with the palette's `glass`, in a window opened `WindowBackgroundAppearance::Blurred`: vibrancy on macOS, acrylic on Windows. gpui 0.2.2 has no Mica. - The chart's hues go by `theme::HUE_NAMES`, for an event's color and a label's alike. - Text whose every character counts, encoded data and keys, goes through `typography::literal`: JetBrains Mono draws `==` and `->` as ligatures (`devtools::Encoder`). +- Messages go through `i18n::I18n`, a global the app sets: `cx.global::().text(key, args)` fills each `{name}`. Every catalog holds the first one's keys; a missing key, argument or catalog fails. A right-to-left locale mirrors a stack with `Direction::row` and sets its text with `Direction::align`; CoreText orders mixed Hebrew and Latin runs on macOS. ## Decisions diff --git a/examples/gallery/main.rs b/examples/gallery/main.rs index fc0c3e2..4eb0c39 100644 --- a/examples/gallery/main.rs +++ b/examples/gallery/main.rs @@ -99,6 +99,7 @@ fn main() -> Result<()> { .run(move |cx: &mut App| { ely_gpui_component::init(cx); pages::bind_keys(cx); + pages::install_i18n(cx); cx.bind_keys([KeyBinding::new("cmd-q", Quit, None)]); cx.on_action(|_: &Quit, cx| cx.quit()); menus(cx); diff --git a/examples/gallery/pages/i18n/mod.rs b/examples/gallery/pages/i18n/mod.rs new file mode 100644 index 0000000..23978d4 --- /dev/null +++ b/examples/gallery/pages/i18n/mod.rs @@ -0,0 +1,217 @@ +use ely_gpui_component::{ + forms::{Choice, Select}, + i18n::{I18n, LOCALES}, + primitives::{Icon, IconName}, + theme::{ActiveTheme, Radius}, +}; +use gpui::{AnyElement, App, IntoElement, ParentElement, Styled, Window, div, px}; +use jiff::{Timestamp, tz::TimeZone}; + +use super::Page; +use crate::probe::probe; +use crate::script::Step; +use crate::ui::{section, specimen, specimens}; + +pub const PAGE: Page = Page { + number: 40, + slug: "i18n", + title: "i18n & a11y", + summary: "Messages, numbers and dates in the reader's locale. A right-to-left locale mirrors its stacks.", + render, + script: &[ + Step::Click("locale"), + Step::Key("end"), + Step::Key("enter"), + Step::Wait(300), + Step::Shot("right-to-left"), + Step::Click("locale"), + Step::Key("home"), + Step::Key("enter"), + Step::Wait(300), + ], +}; + +/// 1 September 2026, when the demo's folder was saved. +const SAVED: i64 = 1_788_220_800; + +/// The gallery's messages, one catalog per listed locale. +pub fn install(cx: &mut App) { + cx.set_global( + I18n::new("en-US") + .catalog( + "en-US", + &[ + ("welcome", "Welcome back, {name}"), + ("files", "{count} files in {folder}"), + ("saved", "Last saved {date}"), + ], + ) + .catalog( + "en-GB", + &[ + ("welcome", "Welcome back, {name}"), + ("files", "{count} files in {folder}"), + ("saved", "Last saved {date}"), + ], + ) + .catalog( + "de-DE", + &[ + ("welcome", "Willkommen zurück, {name}"), + ("files", "{count} Dateien in {folder}"), + ("saved", "Zuletzt gespeichert am {date}"), + ], + ) + .catalog( + "fr-FR", + &[ + ("welcome", "Bon retour, {name}"), + ("files", "{count} fichiers dans {folder}"), + ("saved", "Enregistré le {date}"), + ], + ) + .catalog( + "ja-JP", + &[ + ("welcome", "おかえりなさい、{name}さん"), + ("files", "{folder}に{count}個のファイル"), + ("saved", "最終保存:{date}"), + ], + ) + .catalog( + "zh-CN", + &[ + ("welcome", "欢迎回来,{name}"), + ("files", "{folder}中有{count}个文件"), + ("saved", "上次保存于{date}"), + ], + ) + .catalog( + "he-IL", + &[ + ("welcome", "ברוך שובך, {name}"), + ("files", "{count} קבצים ב־{folder}"), + ("saved", "נשמר לאחרונה ב־{date}"), + ], + ), + ); +} + +fn render(_: &mut Window, cx: &mut App) -> AnyElement { + div() + .child(messages(cx)) + .child(formats(cx)) + .child(mirrored(cx)) + .into_any_element() +} + +fn messages(cx: &App) -> impl IntoElement + use<> { + let i18n = cx.global::(); + let locale = i18n.locale(); + let saved = Timestamp::from_second(SAVED).expect("a time"); + let lines = [ + i18n.text("welcome", &[("name", "Ada")]), + i18n.text( + "files", + &[("count", &locale.number(1204.0, 0)), ("folder", "Docs")], + ), + i18n.text("saved", &[("date", &locale.date(saved, &TimeZone::UTC))]), + ]; + section( + "I18nProvider · Trans / LocalizedText", + "One locale for the app, a catalog of messages per locale. Every catalog holds the same keys; a missing key or argument fails.", + cx, + ) + .child( + specimens() + .child(specimen( + "locale", + probe( + "locale", + div().w(px(240.0)).child( + Select::new( + "locale", + LOCALES.map(|locale| Choice::new(locale.tag, locale.name)), + ) + .selected(locale.tag) + .on_change(|tag, _, cx| I18n::set_locale(tag, cx)), + ), + ), + cx, + )) + .child(specimen( + "messages", + locale.direction.align( + div() + .w(px(280.0)) + .flex() + .flex_col() + .gap_1() + .children(lines.map(|line| div().child(line))), + ), + cx, + )), + ) +} + +fn formats(cx: &App) -> impl IntoElement + use<> { + let theme = cx.theme(); + let saved = Timestamp::from_second(SAVED).expect("a time"); + let rows = LOCALES.map(|locale| { + div() + .flex() + .flex_wrap() + .gap_x_4() + .child( + div() + .w(px(96.0)) + .text_color(theme.colors.fg_muted) + .child(locale.name), + ) + .child(div().w(px(120.0)).child(locale.number(-1234567.891, 2))) + .child(div().child(locale.date(saved, &TimeZone::UTC))) + }); + section( + "LocaleNumber / LocaleDate", + "Each locale's separators and its short numeric date, from CLDR.", + cx, + ) + .child(div().flex().flex_col().gap_1().children(rows)) +} + +fn mirrored(cx: &App) -> impl IntoElement + use<> { + let theme = cx.theme(); + let direction = cx.global::().locale().direction; + let file = |name: &'static str, size: &'static str| { + direction + .row(div()) + .items_center() + .gap_2() + .px_3() + .py_2() + .child(Icon::new(IconName::File).color(theme.colors.fg_muted)) + .child(direction.align(div().flex_1().min_w_0().child(name))) + .child( + div() + .flex_none() + .text_color(theme.colors.fg_muted) + .child(size), + ) + }; + section( + "RTL layout", + "A right-to-left locale starts its stacks and sets its text at the right; pick עברית above. Ely's other components stay left to right.", + cx, + ) + .child( + div() + .max_w(px(320.0)) + .flex() + .flex_col() + .rounded(theme.radius(Radius::Md)) + .border_1() + .border_color(theme.colors.border) + .child(file("Docs", "12 KB")) + .child(file("Photos", "3.1 MB")), + ) +} diff --git a/examples/gallery/pages/mod.rs b/examples/gallery/pages/mod.rs index da9b18a..612ff6a 100644 --- a/examples/gallery/pages/mod.rs +++ b/examples/gallery/pages/mod.rs @@ -18,6 +18,7 @@ mod finance; mod forms; mod generative; mod git; +mod i18n; mod interaction; mod layout; mod lists; @@ -92,8 +93,10 @@ pub const ALL: &[Page] = &[ onboarding::PAGE, interaction::PAGE, theme::PAGE, + i18n::PAGE, ]; +pub use i18n::install as install_i18n; pub use interaction::bind_keys; pub use shell::{open_about, open_managed}; diff --git a/src/i18n/locale.rs b/src/i18n/locale.rs new file mode 100644 index 0000000..c42c88f --- /dev/null +++ b/src/i18n/locale.rs @@ -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(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(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)) + } +} diff --git a/src/i18n/mod.rs b/src/i18n/mod.rs new file mode 100644 index 0000000..2cce09e --- /dev/null +++ b/src/i18n/mod.rs @@ -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::(); + 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 { + 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) +} diff --git a/src/i18n/tests.rs b/src/i18n/tests.rs new file mode 100644 index 0000000..7547410 --- /dev/null +++ b/src/i18n/tests.rs @@ -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) -> impl IntoElement { + let i18n = cx.global::(); + 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::().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)); + } +} diff --git a/src/lib.rs b/src/lib.rs index 4518e8c..f55e24e 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -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; diff --git a/tasks/ch31-43.md b/tasks/ch31-43.md index b3ffd1c..76b2b0c 100644 --- a/tasks/ch31-43.md +++ b/tasks/ch31-43.md @@ -273,10 +273,10 @@ ## T40a i18n — `src/i18n` -- [ ] I18nProvider -- [ ] Trans / LocalizedText -- [ ] LocaleNumber / LocaleDate -- [ ] RTL layout — prove: mirrored stacks +- [x] I18nProvider → `i18n::I18n`, a gpui global: the shown locale and one catalog per locale +- [x] Trans / LocalizedText → `I18n::text`, a message with its `{name}` arguments filled +- [x] LocaleNumber / LocaleDate → `Locale::number` and `Locale::date`, CLDR's separators and short dates +- [x] RTL layout — proven: `Direction::row` mirrors a stack and `Direction::align` sets its text's side; Ely's other components stay left to right ## T40b a11y — `src/i18n`