From 928da801fb014ba4817eee4e73af201ede68d417 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 19:36:12 -0400 Subject: [PATCH] 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. Co-Authored-By: Claude Opus 5.5 --- AGENTS.md | 2 +- examples/gallery/pages/i18n/mod.rs | 8 ++++++++ src/i18n/mod.rs | 11 +++++++---- src/i18n/tests.rs | 25 ++++++++++++++++++++++++- 4 files changed, 40 insertions(+), 6 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 8930650..7a53232 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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::().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::().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 diff --git a/examples/gallery/pages/i18n/mod.rs b/examples/gallery/pages/i18n/mod.rs index 7bd4e0d..652f1b5 100644 --- a/examples/gallery/pages/i18n/mod.rs +++ b/examples/gallery/pages/i18n/mod.rs @@ -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", diff --git a/src/i18n/mod.rs b/src/i18n/mod.rs index 2cce09e..25cc363 100644 --- a/src/i18n/mod.rs +++ b/src/i18n/mod.rs @@ -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(), + } } } diff --git a/src/i18n/tests.rs b/src/i18n/tests.rs index 81b77ed..c1789ae 100644 --- a/src/i18n/tests.rs +++ b/src/i18n/tests.rs @@ -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::().text("hi", &[])), "שלום"); + assert_eq!( + cx.read(|cx| cx.global::().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!(