From d05b1b78ca935175d063ae7a47cb2530e4bad9f3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=B7=E7=94=B5=E8=8A=BD=E8=A1=A3?= Date: Wed, 14 Oct 2026 19:53:00 -0400 Subject: [PATCH] i18n: shipped components read their copy from the catalog Select's default placeholder, the clear, close, remove and unpin tooltips, the completion menu's empty line and the size sample showed English under zh-CN. A source test now fails on any fixed English literal in them. Co-Authored-By: Claude Opus 5.5 (1M context) --- AGENTS.md | 2 +- src/editor/find.rs | 3 ++- src/editor/hints.rs | 3 ++- src/forms/input.rs | 3 ++- src/forms/select.rs | 12 ++++++---- src/i18n/builtin.rs | 20 ++++++++++++++++ src/i18n/tests/mod.rs | 3 ++- src/i18n/tests/shipped.rs | 44 +++++++++++++++++++++++++++++++++++ src/navigation/editor_tabs.rs | 2 +- src/settings/appearance.rs | 4 ++-- src/tables/builders.rs | 5 ++-- 11 files changed, 86 insertions(+), 15 deletions(-) create mode 100644 src/i18n/tests/shipped.rs diff --git a/AGENTS.md b/AGENTS.md index ddfa63a..6292b8c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -226,7 +226,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's `MicaBackdrop` and `MicaAltBackdrop` are DWM backdrops from Windows 11 build 22621; older builds get none, in silence, and macOS plain transparency. - 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, 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, so its words suit labels, not a field's value, and `Locale::number` a right-to-left minus with U+200E, as CLDR does. gpui wraps shaped glyphs in the order they are drawn, on the web as on macOS, 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. Copy a component draws, tooltips and default placeholders too, reads (app catalog, then the builtin English and Chinese); fails on a fixed English literal in a component Ely ships. 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, so its words suit labels, not a field's value, and `Locale::number` a right-to-left minus with U+200E, as CLDR does. gpui wraps shaped glyphs in the order they are drawn, on the web as on macOS, 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. - gpui orders Tab stops by `tab_index` before paint order, and registers none for an `invisible()` element. A stop that must come first where it sits, yet draw over what paints after it, stays in flow at zero size and raises what it shows (`i18n::SkipLink`): a lower `tab_index` would sort it before the root scope's own handle, so Tab from the root would pass it. - Color-blind safe charts swap Ely's chart hues for a set fitted to stay apart under Machado's protanopia, deuteranopia and tritanopia; a test holds every pair 0.10 apart in Oklab and each hue 3:1 on its page (`Theme::set_color_blind_safe`, in Ely's own palettes alone). - gpui packs pictures in its atlas without padding and samples them with a linear filter, on the web as on macOS, so a scaled picture's edge takes color from its atlas neighbor, and pictures laid edge to edge show seams. A map tile carries a one-pixel rim copied from its own edge and draws one of its pixels larger each side, clipped to its box (`maps::tile`). diff --git a/src/editor/find.rs b/src/editor/find.rs index 70822e9..08a38fb 100644 --- a/src/editor/find.rs +++ b/src/editor/find.rs @@ -5,6 +5,7 @@ use gpui::{ div, prelude::*, }; +use crate::i18n; use crate::{ buttons::{Button, ButtonVariant, IconButton, ToggleButton, ToggleItem}, forms::{Input, TextInput}, @@ -295,7 +296,7 @@ impl RenderOnce for FindWidget { IconButton::new((self.id.clone(), "close"), IconName::X) .variant(ButtonVariant::Ghost) .size(ControlSize::Sm) - .tooltip("Close") + .tooltip(i18n::text(cx, "dialog.close", &[])) .on_click(move |_, window, cx| close(window, cx)) })), ) diff --git a/src/editor/hints.rs b/src/editor/hints.rs index 0bf3449..198e61c 100644 --- a/src/editor/hints.rs +++ b/src/editor/hints.rs @@ -7,6 +7,7 @@ use gpui::{ }; use super::{symbols::SymbolKind, syntax}; +use crate::i18n; use crate::{ buttons::{ButtonVariant, IconButton}, navigation::{fuzzy, marked}, @@ -185,7 +186,7 @@ impl RenderOnce for CompletionMenu { .px_2() .py_1() .text_color(colors.fg_subtle) - .child("No suggestions"), + .child(i18n::text(cx, "completion.empty", &[])), ) }) .children(rows), diff --git a/src/forms/input.rs b/src/forms/input.rs index 974bee8..c365f2f 100644 --- a/src/forms/input.rs +++ b/src/forms/input.rs @@ -4,6 +4,7 @@ use gpui::{ }; use super::TextInput; +use crate::i18n; use crate::{ buttons::IconButton, primitives::{Icon, IconName}, @@ -148,7 +149,7 @@ impl RenderOnce for Input { frame.child( IconButton::new(("input-clear", clear_id), IconName::CircleX) .size(ControlSize::Sm) - .tooltip("Clear") + .tooltip(i18n::text(cx, "input.clear", &[])) .on_click(move |_, window, cx| { state.update(cx, |input, cx| input.set_text("", cx)); window.focus(&state.read(cx).focus().clone(), cx); diff --git a/src/forms/select.rs b/src/forms/select.rs index 7b0d22f..8604a42 100644 --- a/src/forms/select.rs +++ b/src/forms/select.rs @@ -251,7 +251,7 @@ pub struct Select { id: ElementId, choices: Vec, selected: Option, - placeholder: SharedString, + placeholder: Option, label: Option, size: ControlSize, disabled: bool, @@ -264,7 +264,7 @@ impl Select { id: id.into(), choices: choices.into_iter().collect(), selected: None, - placeholder: SharedString::from("Choose…"), + placeholder: None, label: None, size: ControlSize::default(), disabled: false, @@ -284,7 +284,7 @@ impl Select { } pub fn placeholder(mut self, text: impl Into) -> Self { - self.placeholder = text.into(); + self.placeholder = Some(text.into()); self } @@ -326,6 +326,8 @@ impl RenderOnce for Select { .as_ref() .and_then(|value| choices.iter().find(|choice| choice.value == *value)) .cloned(); + let placeholder = + (self.placeholder.clone()).unwrap_or_else(|| i18n::text(cx, "select.placeholder", &[])); let colors = &cx.theme().colors; let trigger = field_button( self.id.clone(), @@ -336,7 +338,7 @@ impl RenderOnce for Select { cx, ) .role(Role::ComboBox) - .aria_placeholder(self.placeholder.clone()) + .aria_placeholder(placeholder.clone()) .when_some(self.label.clone(), |trigger, label| { trigger.aria_label(label) }) @@ -361,7 +363,7 @@ impl RenderOnce for Select { ) .child(field_text( shown.map(|choice| choice.label), - self.placeholder, + placeholder, self.disabled, cx, )) diff --git a/src/i18n/builtin.rs b/src/i18n/builtin.rs index 5ac5760..cc8d346 100644 --- a/src/i18n/builtin.rs +++ b/src/i18n/builtin.rs @@ -115,6 +115,16 @@ const EN: &[(&str, &str)] = &[ ("theme.light", "Light"), ("theme.dark", "Dark"), ("theme.system", "System"), + ("select.placeholder", "Choose…"), + ("input.clear", "Clear"), + ("completion.empty", "No suggestions"), + ("builder.remove", "Remove"), + ("tabs.unpin", "Unpin"), + ("theme.size.reset", "Back to the default size"), + ( + "theme.size.sample", + "The quick brown fox jumps over the lazy dog.", + ), ]; const ZH_CN: &[(&str, &str)] = &[ @@ -228,6 +238,16 @@ const ZH_CN: &[(&str, &str)] = &[ ("theme.light", "浅色"), ("theme.dark", "深色"), ("theme.system", "跟随系统"), + ("select.placeholder", "请选择…"), + ("input.clear", "清除"), + ("completion.empty", "没有建议"), + ("builder.remove", "移除"), + ("tabs.unpin", "取消固定"), + ("theme.size.reset", "恢复默认大小"), + ( + "theme.size.sample", + "天地玄黄,宇宙洪荒。The quick brown fox.", + ), ]; /// Ely's message for `key`; English if unshipped, fails if unknown. diff --git a/src/i18n/tests/mod.rs b/src/i18n/tests/mod.rs index 7cbf938..a59f247 100644 --- a/src/i18n/tests/mod.rs +++ b/src/i18n/tests/mod.rs @@ -2,8 +2,9 @@ use jiff::{Timestamp, tz::TimeZone}; use super::{Direction, I18n, LOCALES, Locale, fill}; -#[cfg(feature = "test-support")] mod a11y; +#[cfg(feature = "test-support")] +mod shipped; fn i18n() -> I18n { I18n::new("en-US") diff --git a/src/i18n/tests/shipped.rs b/src/i18n/tests/shipped.rs new file mode 100644 index 0000000..81d40f0 --- /dev/null +++ b/src/i18n/tests/shipped.rs @@ -0,0 +1,44 @@ +//! Components Ely ships say nothing in fixed English. + +/// Sources of components the Ely apps draw. +const SHIPPED: &[(&str, &str)] = &[ + ("editor/find.rs", include_str!("../../editor/find.rs")), + ("editor/hints.rs", include_str!("../../editor/hints.rs")), + ("forms/input.rs", include_str!("../../forms/input.rs")), + ("forms/select.rs", include_str!("../../forms/select.rs")), + ( + "tables/builders.rs", + include_str!("../../tables/builders.rs"), + ), + ( + "navigation/editor_tabs.rs", + include_str!("../../navigation/editor_tabs.rs"), + ), + ( + "settings/appearance.rs", + include_str!("../../settings/appearance.rs"), + ), +]; + +#[test] +fn shipped_components_show_no_fixed_english() { + let shown = [".tooltip(\"", ".child(\"", "SharedString::from(\""]; + let mut found = Vec::new(); + for (path, source) in SHIPPED { + let code = source.split("#[cfg(test)]").next().unwrap_or(source); + for (no, line) in code.lines().enumerate() { + for call in shown { + let Some(at) = line.find(call) else { continue }; + let rest = &line[at + call.len()..]; + if rest.starts_with(|ch: char| ch.is_ascii_alphabetic()) { + found.push(format!("{path}:{}: {}", no + 1, line.trim())); + } + } + } + } + assert!( + found.is_empty(), + "English outside the catalog:\n{}", + found.join("\n") + ); +} diff --git a/src/navigation/editor_tabs.rs b/src/navigation/editor_tabs.rs index b064c67..13b7b4a 100644 --- a/src/navigation/editor_tabs.rs +++ b/src/navigation/editor_tabs.rs @@ -195,7 +195,7 @@ impl RenderOnce for EditorTabs { IconButton::new(("pin", ix), IconName::Pin) .size(ControlSize::Sm) .variant(ButtonVariant::Ghost) - .tooltip("Unpin") + .tooltip(i18n::text(cx, "tabs.unpin", &[])) .on_click(move |_, window, cx| { cx.stop_propagation(); run(&pin, "unpin", &slot_id, window, cx); diff --git a/src/settings/appearance.rs b/src/settings/appearance.rs index f7b74b9..518b8df 100644 --- a/src/settings/appearance.rs +++ b/src/settings/appearance.rs @@ -223,7 +223,7 @@ impl RenderOnce for FontSizeControl { ) .child( IconButton::new((self.id, "reset"), IconName::RotateCcw) - .tooltip("Back to the default size") + .tooltip(i18n::text(cx, "theme.size.reset", &[])) .disabled(self.size == default) .on_click(move |_, window, cx| { reset(default, window, cx); @@ -237,7 +237,7 @@ impl RenderOnce for FontSizeControl { .min_w_0() .text_size(Pixels::from(self.size)) .text_color(theme.colors.fg) - .child("The quick brown fox jumps over the lazy dog."), + .child(i18n::text(cx, "theme.size.sample", &[])), ), ) } diff --git a/src/tables/builders.rs b/src/tables/builders.rs index 2bddf54..3e7a0d2 100644 --- a/src/tables/builders.rs +++ b/src/tables/builders.rs @@ -6,6 +6,7 @@ use gpui::{ }; use super::{FilterRule, SortKey, Test}; +use crate::i18n; use crate::{ buttons::{Button, ButtonVariant, IconButton, SegmentedControl}, forms::{Choice, Input, InputEvent, Select, TextInput}, @@ -205,7 +206,7 @@ impl RenderOnce for FilterBuilder { IconButton::new((id.clone(), format!("remove-{ix}")), IconName::X) .variant(ButtonVariant::Ghost) .size(ControlSize::Sm) - .tooltip("Remove") + .tooltip(i18n::text(cx, "builder.remove", &[])) .on_click(move |_, window, cx| { let mut next = for_remove.clone(); next.remove(ix); @@ -377,7 +378,7 @@ impl RenderOnce for SortBuilder { IconButton::new((id.clone(), format!("sort-remove-{ix}")), IconName::X) .variant(ButtonVariant::Ghost) .size(ControlSize::Sm) - .tooltip("Remove") + .tooltip(i18n::text(cx, "builder.remove", &[])) .on_click(move |_, window, cx| { let mut next = for_remove.clone(); next.remove(ix);