From 219e4744c00a0f17c815691501a0332f13129de5 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:20:13 -0400 Subject: [PATCH] i18n: Hebrew on one line, its sign held left, CLDR's short years gpui 0.2.2 wraps shaped glyphs in the order they are drawn, so a right-to-left line that wraps comes out garbled. The demo's Hebrew lines are Ellipses, which cut by characters, and an Ellipsis now sits by its text's alignment, so a short line under text_right lies at the right; the editor's completion details take the side they ask for. A right-to-left locale leads a minus with a left-to-right mark, as CLDR does, so the sign stays at the digits' left inside Hebrew. en-US and de-DE write CLDR's two-digit year. The repeated-key and second-catalog guards have tests. Co-Authored-By: Claude Opus 5.5 --- AGENTS.md | 2 +- examples/gallery/pages/i18n/mod.rs | 5 ++-- src/i18n/locale.rs | 12 ++++++---- src/i18n/tests.rs | 25 +++++++++++++++++--- src/typography/fit.rs | 38 +++++++++++++++++++++++++----- tasks/ch31-43.md | 4 ++-- 6 files changed, 68 insertions(+), 18 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index aa173a7..8930650 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. +- 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. ## Decisions diff --git a/examples/gallery/pages/i18n/mod.rs b/examples/gallery/pages/i18n/mod.rs index 23978d4..7bd4e0d 100644 --- a/examples/gallery/pages/i18n/mod.rs +++ b/examples/gallery/pages/i18n/mod.rs @@ -3,6 +3,7 @@ use ely_gpui_component::{ i18n::{I18n, LOCALES}, primitives::{Icon, IconName}, theme::{ActiveTheme, Radius}, + typography::Ellipsis, }; use gpui::{AnyElement, App, IntoElement, ParentElement, Styled, Window, div, px}; use jiff::{Timestamp, tz::TimeZone}; @@ -147,7 +148,7 @@ fn messages(cx: &App) -> impl IntoElement + use<> { .flex() .flex_col() .gap_1() - .children(lines.map(|line| div().child(line))), + .children(lines.map(Ellipsis::new)), ), cx, )), @@ -190,7 +191,7 @@ fn mirrored(cx: &App) -> impl IntoElement + use<> { .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(direction.align(div().flex_1().min_w_0().child(Ellipsis::new(name)))) .child( div() .flex_none() diff --git a/src/i18n/locale.rs b/src/i18n/locale.rs index c42c88f..7593c72 100644 --- a/src/i18n/locale.rs +++ b/src/i18n/locale.rs @@ -61,7 +61,7 @@ pub const LOCALES: [Locale; 7] = [ "en-US", "English (US)", (',', '.'), - "%-m/%-d/%Y", + "%-m/%-d/%y", Direction::Ltr, ), locale( @@ -71,7 +71,7 @@ pub const LOCALES: [Locale; 7] = [ "%d/%m/%Y", Direction::Ltr, ), - locale("de-DE", "Deutsch", ('.', ','), "%d.%m.%Y", Direction::Ltr), + locale("de-DE", "Deutsch", ('.', ','), "%d.%m.%y", Direction::Ltr), locale( "fr-FR", "Français", @@ -93,9 +93,13 @@ impl Locale { .unwrap_or_else(|| panic!("no locale {tag:?}")) } - /// `value` with this locale's separators. + /// `value` with this locale's separators; a right-to-left locale leads its sign with a left-to-right mark, as CLDR does. pub fn number(self, value: f64, decimals: usize) -> String { - format::number(value, decimals, self.separators) + let number = format::number(value, decimals, self.separators); + match (self.direction, number.starts_with(format::MINUS)) { + (Direction::Rtl, true) => format!("\u{200e}{number}"), + _ => number, + } } /// The day `at` falls on in `zone`, written as this locale writes dates. diff --git a/src/i18n/tests.rs b/src/i18n/tests.rs index 7547410..81b77ed 100644 --- a/src/i18n/tests.rs +++ b/src/i18n/tests.rs @@ -53,6 +53,20 @@ fn a_catalog_with_a_stray_key_fails() { .catalog("de-DE", &[("hi", "Hallo"), ("bye", "Tschüss")]); } +#[test] +#[should_panic(expected = "en-US repeats a key")] +fn a_repeated_key_fails() { + I18n::new("en-US").catalog("en-US", &[("hi", "Hello"), ("hi", "Hey")]); +} + +#[test] +#[should_panic(expected = "en-US has a catalog")] +fn a_second_catalog_for_a_locale_fails() { + I18n::new("en-US") + .catalog("en-US", &[("hi", "Hello")]) + .catalog("en-US", &[("hi", "Hey")]); +} + #[test] #[should_panic(expected = "no locale \"xx-XX\"")] fn an_unlisted_locale_fails() { @@ -76,9 +90,9 @@ fn locales_write_their_own_numbers_and_dates() { assert_eq!( cases, [ - ("en-US", "−1,234,567.89".into(), "9/1/2026".into()), + ("en-US", "−1,234,567.89".into(), "9/1/26".into()), ("en-GB", "−1,234,567.89".into(), "01/09/2026".into()), - ("de-DE", "−1.234.567,89".into(), "01.09.2026".into()), + ("de-DE", "−1.234.567,89".into(), "01.09.26".into()), ( "fr-FR", "−1\u{202f}234\u{202f}567,89".into(), @@ -86,7 +100,7 @@ fn locales_write_their_own_numbers_and_dates() { ), ("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()), + ("he-IL", "\u{200e}−1,234,567.89".into(), "1.9.2026".into()), ] ); let rtl: Vec<_> = LOCALES @@ -94,6 +108,11 @@ fn locales_write_their_own_numbers_and_dates() { .filter(|locale| locale.direction == Direction::Rtl) .collect(); assert_eq!(rtl, [&Locale::of("he-IL")]); + assert_eq!( + Locale::of("he-IL").number(3.5, 1), + "3.5", + "a gain takes no mark" + ); } #[cfg(feature = "test-support")] diff --git a/src/typography/fit.rs b/src/typography/fit.rs index f4612b9..4f6f7a3 100644 --- a/src/typography/fit.rs +++ b/src/typography/fit.rs @@ -1,7 +1,7 @@ use gpui::{ App, Div, ElementId, InteractiveElement, IntoElement, ParentElement, Pixels, RenderOnce, - ShapedLine, SharedString, StatefulInteractiveElement, Styled, TextRun, Window, canvas, div, - prelude::*, relative, transparent_black, + ShapedLine, SharedString, StatefulInteractiveElement, Styled, TextAlign, TextRun, Window, + canvas, div, point, prelude::*, relative, transparent_black, }; use crate::{ @@ -101,7 +101,17 @@ fn fit_end(text: &str, width: Pixels, window: &mut Window) -> String { cut(low) } -/// One line in the text style around it, cut by `fit` to its box. gpui 0.2.2 keeps a line's first measure, so its own `truncate` clips instead in flex boxes. +/// Where a line starts in a box with `free` width to spare. +fn offset(align: TextAlign, free: Pixels) -> Pixels { + let free = free.max(Pixels::ZERO); + match align { + TextAlign::Left => Pixels::ZERO, + TextAlign::Center => free / 2., + TextAlign::Right => free, + } +} + +/// One line in the text style around it, cut by `fit` to its box and set by its alignment. gpui 0.2.2 keeps a line's first measure, so its own `truncate` clips instead in flex boxes. fn cut_line(text: SharedString, fit: fn(&str, Pixels, &mut Window) -> String) -> Div { let line = text.clone(); div() @@ -114,9 +124,11 @@ fn cut_line(text: SharedString, fit: fn(&str, Pixels, &mut Window) -> String) -> canvas( move |bounds, window, _| shape(&fit(&line, bounds.size.width, window), window), |bounds, shaped, window, cx| { - let line = window.text_style().line_height_in_pixels(window.rem_size()); + let style = window.text_style(); + let line = style.line_height_in_pixels(window.rem_size()); + let x = offset(style.text_align, bounds.size.width - shaped.width); shaped - .paint(bounds.origin, line, window, cx) + .paint(bounds.origin + point(x, Pixels::ZERO), line, window, cx) .expect("a cut line paints"); }, ) @@ -224,7 +236,21 @@ impl RenderOnce for EllipsisTooltip { #[cfg(test)] mod tests { - use super::elide; + use gpui::{TextAlign, px}; + + use super::{elide, offset}; + + #[test] + fn a_line_starts_by_its_alignment() { + assert_eq!(offset(TextAlign::Left, px(40.)), px(0.)); + assert_eq!(offset(TextAlign::Center, px(40.)), px(20.)); + assert_eq!(offset(TextAlign::Right, px(40.)), px(40.)); + assert_eq!( + offset(TextAlign::Right, px(-8.)), + px(0.), + "a cut line fills its box" + ); + } #[test] fn elide_keeps_both_ends() { diff --git a/tasks/ch31-43.md b/tasks/ch31-43.md index 76b2b0c..3aa511e 100644 --- a/tasks/ch31-43.md +++ b/tasks/ch31-43.md @@ -275,8 +275,8 @@ - [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 +- [x] LocaleNumber / LocaleDate → `Locale::number` and `Locale::date`, CLDR's separators, right-to-left sign and short dates +- [x] RTL layout — proven: `Direction::row` mirrors a stack and `Direction::align` sets its text's side; a right-to-left line stays one `Ellipsis`, since gpui wraps glyphs in drawn order; Ely's other components stay left to right ## T40b a11y — `src/i18n`