i18n: a right-to-left message leads with its mark

gpui sets no paragraph direction, so CoreText took a Hebrew line whose
first strong character was Latin, or the left-to-right mark of a signed
number, as left to right and swapped its parts. I18n::text now leads a
right-to-left message with U+200F; the number keeps CLDR's U+200E, so
its sign stays at the digits' left. The demo adds a change that opens
with a signed number, in every catalog; under Hebrew it lies at the
line's right end. A test holds the marks on a message that starts with
a number and one that starts with a Latin name.
This commit is contained in:
ZacharyZhang-NY committed 2026-09-27 19:36:12 -04:00
1 parent 83ecacde65
commit 451fdcc7c0
4 files changed
+40 -6

No files matched your search

+1 -1
View File
@@ -190,7 +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::<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. gpui 0.2.2 wraps shaped glyphs in the order they are drawn, so a right-to-left line that wraps comes out garbled: right-to-left text stays on one line, an `Ellipsis`, which cuts by characters and sits by its text's alignment.
- 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, but gpui sets no paragraph direction, so a line takes its first strong character's: `text` leads a right-to-left message with U+200F, and `Locale::number` a right-to-left minus with U+200E, as CLDR does. gpui 0.2.2 wraps shaped glyphs in the order they are drawn, so a right-to-left line that wraps comes out garbled: right-to-left text stays on one line, an `Ellipsis`, which cuts by characters and sits by its text's alignment.
## Decisions
+8
View File
@@ -45,6 +45,7 @@ pub fn install(cx: &mut App) {
("welcome", "Welcome back, {name}"),
("files", "{count} files in {folder}"),
("saved", "Last saved {date}"),
("change", "{change}% since yesterday"),
],
)
.catalog(
@@ -53,6 +54,7 @@ pub fn install(cx: &mut App) {
("welcome", "Welcome back, {name}"),
("files", "{count} files in {folder}"),
("saved", "Last saved {date}"),
("change", "{change}% since yesterday"),
],
)
.catalog(
@@ -61,6 +63,7 @@ pub fn install(cx: &mut App) {
("welcome", "Willkommen zurück, {name}"),
("files", "{count} Dateien in {folder}"),
("saved", "Zuletzt gespeichert am {date}"),
("change", "{change} % seit gestern"),
],
)
.catalog(
@@ -69,6 +72,7 @@ pub fn install(cx: &mut App) {
("welcome", "Bon retour, {name}"),
("files", "{count} fichiers dans {folder}"),
("saved", "Enregistré le {date}"),
("change", "{change}\u{202f}% depuis hier"),
],
)
.catalog(
@@ -77,6 +81,7 @@ pub fn install(cx: &mut App) {
("welcome", "おかえりなさい、{name}さん"),
("files", "{folder}に{count}個のファイル"),
("saved", "最終保存:{date}"),
("change", "昨日から{change}%"),
],
)
.catalog(
@@ -85,6 +90,7 @@ pub fn install(cx: &mut App) {
("welcome", "欢迎回来,{name}"),
("files", "{folder}中有{count}个文件"),
("saved", "上次保存于{date}"),
("change", "较昨日{change}%"),
],
)
.catalog(
@@ -93,6 +99,7 @@ pub fn install(cx: &mut App) {
("welcome", "ברוך שובך, {name}"),
("files", "{count} קבצים ב־{folder}"),
("saved", "נשמר לאחרונה ב־{date}"),
("change", "{change}% מאז אתמול"),
],
),
);
@@ -117,6 +124,7 @@ fn messages(cx: &App) -> impl IntoElement + use<> {
&[("count", &locale.number(1204.0, 0)), ("folder", "Docs")],
),
i18n.text("saved", &[("date", &locale.date(saved, &TimeZone::UTC))]),
i18n.text("change", &[("change", &locale.number(-3.5, 1))]),
];
section(
"I18nProvider · Trans / LocalizedText",
+7 -4
View File
@@ -71,7 +71,7 @@ impl I18n {
cx.refresh_windows();
}
/// The shown locale's message for `key`, each `{name}` filled from `args`; fails on a missing key or argument.
/// The shown locale's message for `key`, each `{name}` filled from `args`; fails on a missing key or argument. gpui sets no paragraph direction, so a right-to-left message leads with a right-to-left mark: CoreText would lay out one that starts with Latin letters or a signed number left to right.
pub fn text(&self, key: &str, args: &[(&str, &str)]) -> SharedString {
let (_, catalog) = self
.catalogs
@@ -81,9 +81,12 @@ impl I18n {
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()
let text = fill(message, args)
.unwrap_or_else(|error| panic!("{key:?} in {}: {error}", self.locale.tag));
match self.locale.direction {
Direction::Ltr => text.into(),
Direction::Rtl => format!("\u{200f}{text}").into(),
}
}
}
+24 -1
View File
@@ -25,6 +25,26 @@ fn a_message_fills_its_arguments() {
assert_eq!(fill("{b}", &[("a", "x")]), Err("no argument \"b\"".into()));
}
/// A right-to-left message leads with its mark, so one that starts with Latin letters or a signed number still reads right to left.
#[test]
fn a_right_to_left_message_leads_with_its_mark() {
let he = I18n::new("he-IL").catalog(
"he-IL",
&[("change", "{change}% מאז אתמול"), ("sent", "{name} שלח")],
);
let change = Locale::of("he-IL").number(-3.5, 1);
assert_eq!(
he.text("change", &[("change", &change)]),
"\u{200f}\u{200e}−3.5% מאז אתמול"
);
assert_eq!(he.text("sent", &[("name", "Ada")]), "\u{200f}Ada שלח");
assert_eq!(
i18n().text("hi", &[]),
"Hello",
"a left-to-right message takes no mark"
);
}
#[test]
#[should_panic(expected = "\"files\" in en-US: no argument \"folder\"")]
fn a_missing_argument_fails() {
@@ -160,7 +180,10 @@ mod shown {
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", &[])), "שלום");
assert_eq!(
cx.read(|cx| cx.global::<I18n>().text("hi", &[])),
"\u{200f}שלום"
);
let first = cx.debug_bounds("first").expect("the first mark");
let second = cx.debug_bounds("second").expect("the second mark");
assert!(