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:
1 parent
17e3e19fc9
commit
ae5f2334f5
9 files changed
+616
-4
No files matched your search
@@ -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.
|
- 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.
|
- 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`).
|
- 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::<I18n>().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
|
## Decisions
|
||||||
|
|
||||||
|
|||||||
@@ -99,6 +99,7 @@ fn main() -> Result<()> {
|
|||||||
.run(move |cx: &mut App| {
|
.run(move |cx: &mut App| {
|
||||||
ely_gpui_component::init(cx);
|
ely_gpui_component::init(cx);
|
||||||
pages::bind_keys(cx);
|
pages::bind_keys(cx);
|
||||||
|
pages::install_i18n(cx);
|
||||||
cx.bind_keys([KeyBinding::new("cmd-q", Quit, None)]);
|
cx.bind_keys([KeyBinding::new("cmd-q", Quit, None)]);
|
||||||
cx.on_action(|_: &Quit, cx| cx.quit());
|
cx.on_action(|_: &Quit, cx| cx.quit());
|
||||||
menus(cx);
|
menus(cx);
|
||||||
|
|||||||
@@ -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::<I18n>();
|
||||||
|
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::<I18n>().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")),
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -18,6 +18,7 @@ mod finance;
|
|||||||
mod forms;
|
mod forms;
|
||||||
mod generative;
|
mod generative;
|
||||||
mod git;
|
mod git;
|
||||||
|
mod i18n;
|
||||||
mod interaction;
|
mod interaction;
|
||||||
mod layout;
|
mod layout;
|
||||||
mod lists;
|
mod lists;
|
||||||
@@ -92,8 +93,10 @@ pub const ALL: &[Page] = &[
|
|||||||
onboarding::PAGE,
|
onboarding::PAGE,
|
||||||
interaction::PAGE,
|
interaction::PAGE,
|
||||||
theme::PAGE,
|
theme::PAGE,
|
||||||
|
i18n::PAGE,
|
||||||
];
|
];
|
||||||
|
|
||||||
|
pub use i18n::install as install_i18n;
|
||||||
pub use interaction::bind_keys;
|
pub use interaction::bind_keys;
|
||||||
pub use shell::{open_about, open_managed};
|
pub use shell::{open_about, open_managed};
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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)
|
||||||
|
}
|
||||||
@@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -20,6 +20,7 @@ pub mod finance;
|
|||||||
pub mod forms;
|
pub mod forms;
|
||||||
pub mod generative;
|
pub mod generative;
|
||||||
pub mod git;
|
pub mod git;
|
||||||
|
pub mod i18n;
|
||||||
pub mod interaction;
|
pub mod interaction;
|
||||||
pub mod layout;
|
pub mod layout;
|
||||||
pub mod lists;
|
pub mod lists;
|
||||||
|
|||||||
+4
-4
@@ -273,10 +273,10 @@
|
|||||||
|
|
||||||
## T40a i18n — `src/i18n`
|
## T40a i18n — `src/i18n`
|
||||||
|
|
||||||
- [ ] I18nProvider
|
- [x] I18nProvider → `i18n::I18n`, a gpui global: the shown locale and one catalog per locale
|
||||||
- [ ] Trans / LocalizedText
|
- [x] Trans / LocalizedText → `I18n::text`, a message with its `{name}` arguments filled
|
||||||
- [ ] LocaleNumber / LocaleDate
|
- [x] LocaleNumber / LocaleDate → `Locale::number` and `Locale::date`, CLDR's separators and short dates
|
||||||
- [ ] RTL layout — prove: mirrored stacks
|
- [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`
|
## T40b a11y — `src/i18n`
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user