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);