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 <noreply@anthropic.com>
This commit is contained in:
1 parent
2a91218fd8
commit
219e4744c0
6 files changed
+68
-18
No files matched your search
@@ -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.
|
- 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.
|
- 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.
|
||||||
|
|
||||||
## Decisions
|
## Decisions
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ use ely_gpui_component::{
|
|||||||
i18n::{I18n, LOCALES},
|
i18n::{I18n, LOCALES},
|
||||||
primitives::{Icon, IconName},
|
primitives::{Icon, IconName},
|
||||||
theme::{ActiveTheme, Radius},
|
theme::{ActiveTheme, Radius},
|
||||||
|
typography::Ellipsis,
|
||||||
};
|
};
|
||||||
use gpui::{AnyElement, App, IntoElement, ParentElement, Styled, Window, div, px};
|
use gpui::{AnyElement, App, IntoElement, ParentElement, Styled, Window, div, px};
|
||||||
use jiff::{Timestamp, tz::TimeZone};
|
use jiff::{Timestamp, tz::TimeZone};
|
||||||
@@ -147,7 +148,7 @@ fn messages(cx: &App) -> impl IntoElement + use<> {
|
|||||||
.flex()
|
.flex()
|
||||||
.flex_col()
|
.flex_col()
|
||||||
.gap_1()
|
.gap_1()
|
||||||
.children(lines.map(|line| div().child(line))),
|
.children(lines.map(Ellipsis::new)),
|
||||||
),
|
),
|
||||||
cx,
|
cx,
|
||||||
)),
|
)),
|
||||||
@@ -190,7 +191,7 @@ fn mirrored(cx: &App) -> impl IntoElement + use<> {
|
|||||||
.px_3()
|
.px_3()
|
||||||
.py_2()
|
.py_2()
|
||||||
.child(Icon::new(IconName::File).color(theme.colors.fg_muted))
|
.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(
|
.child(
|
||||||
div()
|
div()
|
||||||
.flex_none()
|
.flex_none()
|
||||||
|
|||||||
+8
-4
@@ -61,7 +61,7 @@ pub const LOCALES: [Locale; 7] = [
|
|||||||
"en-US",
|
"en-US",
|
||||||
"English (US)",
|
"English (US)",
|
||||||
(',', '.'),
|
(',', '.'),
|
||||||
"%-m/%-d/%Y",
|
"%-m/%-d/%y",
|
||||||
Direction::Ltr,
|
Direction::Ltr,
|
||||||
),
|
),
|
||||||
locale(
|
locale(
|
||||||
@@ -71,7 +71,7 @@ pub const LOCALES: [Locale; 7] = [
|
|||||||
"%d/%m/%Y",
|
"%d/%m/%Y",
|
||||||
Direction::Ltr,
|
Direction::Ltr,
|
||||||
),
|
),
|
||||||
locale("de-DE", "Deutsch", ('.', ','), "%d.%m.%Y", Direction::Ltr),
|
locale("de-DE", "Deutsch", ('.', ','), "%d.%m.%y", Direction::Ltr),
|
||||||
locale(
|
locale(
|
||||||
"fr-FR",
|
"fr-FR",
|
||||||
"Français",
|
"Français",
|
||||||
@@ -93,9 +93,13 @@ impl Locale {
|
|||||||
.unwrap_or_else(|| panic!("no locale {tag:?}"))
|
.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 {
|
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.
|
/// The day `at` falls on in `zone`, written as this locale writes dates.
|
||||||
|
|||||||
+22
-3
@@ -53,6 +53,20 @@ fn a_catalog_with_a_stray_key_fails() {
|
|||||||
.catalog("de-DE", &[("hi", "Hallo"), ("bye", "Tschüss")]);
|
.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]
|
#[test]
|
||||||
#[should_panic(expected = "no locale \"xx-XX\"")]
|
#[should_panic(expected = "no locale \"xx-XX\"")]
|
||||||
fn an_unlisted_locale_fails() {
|
fn an_unlisted_locale_fails() {
|
||||||
@@ -76,9 +90,9 @@ fn locales_write_their_own_numbers_and_dates() {
|
|||||||
assert_eq!(
|
assert_eq!(
|
||||||
cases,
|
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()),
|
("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",
|
"fr-FR",
|
||||||
"−1\u{202f}234\u{202f}567,89".into(),
|
"−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()),
|
("ja-JP", "−1,234,567.89".into(), "2026/09/01".into()),
|
||||||
("zh-CN", "−1,234,567.89".into(), "2026/9/1".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
|
let rtl: Vec<_> = LOCALES
|
||||||
@@ -94,6 +108,11 @@ fn locales_write_their_own_numbers_and_dates() {
|
|||||||
.filter(|locale| locale.direction == Direction::Rtl)
|
.filter(|locale| locale.direction == Direction::Rtl)
|
||||||
.collect();
|
.collect();
|
||||||
assert_eq!(rtl, [&Locale::of("he-IL")]);
|
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")]
|
#[cfg(feature = "test-support")]
|
||||||
|
|||||||
+32
-6
@@ -1,7 +1,7 @@
|
|||||||
use gpui::{
|
use gpui::{
|
||||||
App, Div, ElementId, InteractiveElement, IntoElement, ParentElement, Pixels, RenderOnce,
|
App, Div, ElementId, InteractiveElement, IntoElement, ParentElement, Pixels, RenderOnce,
|
||||||
ShapedLine, SharedString, StatefulInteractiveElement, Styled, TextRun, Window, canvas, div,
|
ShapedLine, SharedString, StatefulInteractiveElement, Styled, TextAlign, TextRun, Window,
|
||||||
prelude::*, relative, transparent_black,
|
canvas, div, point, prelude::*, relative, transparent_black,
|
||||||
};
|
};
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
@@ -101,7 +101,17 @@ fn fit_end(text: &str, width: Pixels, window: &mut Window) -> String {
|
|||||||
cut(low)
|
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 {
|
fn cut_line(text: SharedString, fit: fn(&str, Pixels, &mut Window) -> String) -> Div {
|
||||||
let line = text.clone();
|
let line = text.clone();
|
||||||
div()
|
div()
|
||||||
@@ -114,9 +124,11 @@ fn cut_line(text: SharedString, fit: fn(&str, Pixels, &mut Window) -> String) ->
|
|||||||
canvas(
|
canvas(
|
||||||
move |bounds, window, _| shape(&fit(&line, bounds.size.width, window), window),
|
move |bounds, window, _| shape(&fit(&line, bounds.size.width, window), window),
|
||||||
|bounds, shaped, window, cx| {
|
|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
|
shaped
|
||||||
.paint(bounds.origin, line, window, cx)
|
.paint(bounds.origin + point(x, Pixels::ZERO), line, window, cx)
|
||||||
.expect("a cut line paints");
|
.expect("a cut line paints");
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
@@ -224,7 +236,21 @@ impl RenderOnce for EllipsisTooltip {
|
|||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
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]
|
#[test]
|
||||||
fn elide_keeps_both_ends() {
|
fn elide_keeps_both_ends() {
|
||||||
|
|||||||
+2
-2
@@ -275,8 +275,8 @@
|
|||||||
|
|
||||||
- [x] I18nProvider → `i18n::I18n`, a gpui global: the shown locale and one catalog per locale
|
- [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] 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] 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; Ely's other components stay left to right
|
- [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`
|
## T40b a11y — `src/i18n`
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user