diff --git a/AGENTS.md b/AGENTS.md index b8faa2f..bd73968 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. Copy a component draws, tooltips and default placeholders too, reads `i18n::text` (app catalog, then the builtin English and Chinese); `i18n/tests/shipped.rs` 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. +- 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 `i18n::text` (app catalog, then the builtin English and Chinese); `i18n/tests/shipped.rs` fails on a fixed English literal in a component Ely ships (labels, tooltips, button and segment text, match arms, capitalized literal arguments); a choice's value stays its key, its label the catalog's (`tables::Test::key`). 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 08a38fb..e783f03 100644 --- a/src/editor/find.rs +++ b/src/editor/find.rs @@ -149,6 +149,26 @@ impl FindWidget { } } +/// The count shown, and whether it reads as a miss. +pub(crate) fn count( + matches: &Result<(Option, usize), SharedString>, + cx: &App, +) -> (SharedString, bool) { + match matches { + Ok((_, 0)) => (i18n::text(cx, "find.none", &[]), true), + Ok((Some(current), total)) => { + let (at, total) = ((current + 1).to_string(), total.to_string()); + let args = [("at", at.as_str()), ("total", total.as_str())]; + (i18n::text(cx, "find.at", &args), false) + } + Ok((None, total)) => { + let total = total.to_string(); + (i18n::text(cx, "find.found", &[("total", &total)]), false) + } + Err(message) => (message.clone(), true), + } +} + impl RenderOnce for FindWidget { fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement { let theme = cx.theme(); @@ -156,7 +176,7 @@ impl RenderOnce for FindWidget { let options = self.options; let toggle = |key: &'static str, icon: IconName, - words: &'static str, + words: SharedString, on: bool, set: fn(&mut FindOptions, bool)| { let on_options = self.on_options.clone(); @@ -175,16 +195,11 @@ impl RenderOnce for FindWidget { } }) }; - let (count, missing) = match &self.matches { - Ok((_, 0)) => ("No results".to_string(), true), - Ok((Some(current), total)) => (format!("{} of {total}", current + 1), false), - Ok((None, total)) => (format!("{total} found"), false), - Err(message) => (message.to_string(), true), - }; + let (count, missing) = count(&self.matches, cx); let found = matches!(self.matches, Ok((_, total)) if total > 0); let icon_button = |key: &'static str, icon: IconName, - words: &'static str, + words: SharedString, enabled: bool, action: Option, value: bool| { @@ -230,7 +245,7 @@ impl RenderOnce for FindWidget { } else { IconName::ChevronRight }, - "Toggle replace", + i18n::text(cx, "find.fold", &[]), true, Some(fold), !showing, @@ -246,21 +261,21 @@ impl RenderOnce for FindWidget { row.child(toggle( "case", IconName::CaseSensitive, - "Match case", + i18n::text(cx, "find.case", &[]), options.case, |next, on| next.case = on, )) .child(toggle( "word", IconName::WholeWord, - "Whole word", + i18n::text(cx, "find.word", &[]), options.word, |next, on| next.word = on, )) .child(toggle( "regex", IconName::Regex, - "Regular expression", + i18n::text(cx, "find.regex", &[]), options.regex, |next, on| next.regex = on, )) @@ -279,7 +294,7 @@ impl RenderOnce for FindWidget { .child(icon_button( "previous", IconName::ArrowUp, - "Previous match", + i18n::text(cx, "find.previous", &[]), found, self.on_step.clone(), false, @@ -287,7 +302,7 @@ impl RenderOnce for FindWidget { .child(icon_button( "next", IconName::ArrowDown, - "Next match", + i18n::text(cx, "find.next", &[]), found, self.on_step.clone(), true, @@ -313,22 +328,28 @@ impl RenderOnce for FindWidget { .child(Input::new(field).size(ControlSize::Sm)), ) .child( - Button::new((self.id.clone(), "replace"), "Replace") - .variant(ButtonVariant::Ghost) - .size(ControlSize::Sm) - .disabled(!found) - .when_some(one, |button, one| { - button.on_click(move |_, window, cx| one(false, window, cx)) - }), + Button::new( + (self.id.clone(), "replace"), + i18n::text(cx, "find.replace", &[]), + ) + .variant(ButtonVariant::Ghost) + .size(ControlSize::Sm) + .disabled(!found) + .when_some(one, |button, one| { + button.on_click(move |_, window, cx| one(false, window, cx)) + }), ) .child( - Button::new((self.id.clone(), "all"), "Replace all") - .variant(ButtonVariant::Ghost) - .size(ControlSize::Sm) - .disabled(!found) - .when_some(all, |button, all| { - button.on_click(move |_, window, cx| all(true, window, cx)) - }), + Button::new( + (self.id.clone(), "all"), + i18n::text(cx, "find.replace_all", &[]), + ) + .variant(ButtonVariant::Ghost) + .size(ControlSize::Sm) + .disabled(!found) + .when_some(all, |button, all| { + button.on_click(move |_, window, cx| all(true, window, cx)) + }), ) })) } diff --git a/src/editor/tests/chinese.rs b/src/editor/tests/chinese.rs new file mode 100644 index 0000000..a399655 --- /dev/null +++ b/src/editor/tests/chinese.rs @@ -0,0 +1,40 @@ +//! The find box reads in Chinese under zh-CN. + +use gpui::{Entity, IntoElement, Render, TestAppContext, Window, div, prelude::*}; + +use crate::{forms::TextInput, i18n::I18n, theme::Theme}; + +struct Host(Entity, Entity); + +impl Render for Host { + fn render(&mut self, _: &mut Window, _: &mut gpui::Context) -> impl IntoElement { + let find = super::super::FindWidget::new("find", &self.0, Some(0), 2) + .replace(&self.1) + .on_options(|_, _, _| {}) + .on_step(|_, _, _| {}) + .on_toggle_replace(|_, _, _| {}) + .on_close(|_, _| {}); + div().size_full().child(find) + } +} + +#[gpui::test] +fn the_find_box_reads_in_chinese(cx: &mut TestAppContext) { + cx.update(|cx| { + Theme::init(cx); + cx.set_global(I18n::new("zh-CN").catalog("zh-CN", &[])); + }); + cx.update(|cx| { + let count = |matches| super::super::find::count(&matches, cx); + assert_eq!(count(Ok((None, 0))), ("无结果".into(), true)); + assert_eq!(count(Ok((Some(0), 2))), ("第 1 项,共 2 项".into(), false)); + assert_eq!(count(Ok((None, 3))), ("找到 3 项".into(), false)); + }); + let (_, cx) = cx.add_window_view(|window, cx| { + Host( + cx.new(|cx| TextInput::new(window, cx)), + cx.new(|cx| TextInput::new(window, cx)), + ) + }); + cx.run_until_parked(); +} diff --git a/src/editor/tests/mod.rs b/src/editor/tests/mod.rs index 9d0dabe..6f285c0 100644 --- a/src/editor/tests/mod.rs +++ b/src/editor/tests/mod.rs @@ -1,3 +1,4 @@ +mod chinese; mod history; mod stale; diff --git a/src/forms/input.rs b/src/forms/input.rs index c365f2f..de97e49 100644 --- a/src/forms/input.rs +++ b/src/forms/input.rs @@ -199,11 +199,15 @@ impl RenderOnce for PasswordInput { }, ) .size(ControlSize::Sm) - .tooltip(if masked { - "Show password" - } else { - "Hide password" - }) + .tooltip(i18n::text( + cx, + if masked { + "input.show_password" + } else { + "input.hide_password" + }, + &[], + )) .on_click(move |_, _, cx| { toggle.update(cx, |input, cx| { let masked = input.is_masked(); diff --git a/src/i18n/builtin.rs b/src/i18n/builtin.rs index cc8d346..71a2798 100644 --- a/src/i18n/builtin.rs +++ b/src/i18n/builtin.rs @@ -117,9 +117,34 @@ const EN: &[(&str, &str)] = &[ ("theme.system", "System"), ("select.placeholder", "Choose…"), ("input.clear", "Clear"), + ("input.show_password", "Show password"), + ("input.hide_password", "Hide password"), ("completion.empty", "No suggestions"), ("builder.remove", "Remove"), ("tabs.unpin", "Unpin"), + ("find.none", "No results"), + ("find.at", "{at} of {total}"), + ("find.found", "{total} found"), + ("find.fold", "Toggle replace"), + ("find.case", "Match case"), + ("find.word", "Whole word"), + ("find.regex", "Regular expression"), + ("find.previous", "Previous match"), + ("find.next", "Next match"), + ("find.replace", "Replace"), + ("find.replace_all", "Replace all"), + ("builder.add_rule", "Add rule"), + ("builder.all", "Match all"), + ("builder.any", "Match any"), + ("builder.add_sort", "Add sort"), + ("builder.up", "Ascending"), + ("builder.down", "Descending"), + ("builder.test.contains", "contains"), + ("builder.test.equals", "is"), + ("builder.test.not_equals", "is not"), + ("builder.test.above", "is above"), + ("builder.test.below", "is below"), + ("builder.test.empty", "is empty"), ("theme.size.reset", "Back to the default size"), ( "theme.size.sample", @@ -240,9 +265,34 @@ const ZH_CN: &[(&str, &str)] = &[ ("theme.system", "跟随系统"), ("select.placeholder", "请选择…"), ("input.clear", "清除"), + ("input.show_password", "显示密码"), + ("input.hide_password", "隐藏密码"), ("completion.empty", "没有建议"), ("builder.remove", "移除"), ("tabs.unpin", "取消固定"), + ("find.none", "无结果"), + ("find.at", "第 {at} 项,共 {total} 项"), + ("find.found", "找到 {total} 项"), + ("find.fold", "切换替换"), + ("find.case", "区分大小写"), + ("find.word", "全字匹配"), + ("find.regex", "使用正则表达式"), + ("find.previous", "上一个匹配项"), + ("find.next", "下一个匹配项"), + ("find.replace", "替换"), + ("find.replace_all", "全部替换"), + ("builder.add_rule", "添加规则"), + ("builder.all", "满足全部"), + ("builder.any", "满足任一"), + ("builder.add_sort", "添加排序"), + ("builder.up", "升序"), + ("builder.down", "降序"), + ("builder.test.contains", "包含"), + ("builder.test.equals", "等于"), + ("builder.test.not_equals", "不等于"), + ("builder.test.above", "大于"), + ("builder.test.below", "小于"), + ("builder.test.empty", "为空"), ("theme.size.reset", "恢复默认大小"), ( "theme.size.sample", diff --git a/src/i18n/tests/shipped.rs b/src/i18n/tests/shipped.rs index 81d40f0..437014e 100644 --- a/src/i18n/tests/shipped.rs +++ b/src/i18n/tests/shipped.rs @@ -10,6 +10,7 @@ const SHIPPED: &[(&str, &str)] = &[ "tables/builders.rs", include_str!("../../tables/builders.rs"), ), + ("tables/rules.rs", include_str!("../../tables/rules.rs")), ( "navigation/editor_tabs.rs", include_str!("../../navigation/editor_tabs.rs"), @@ -22,17 +23,46 @@ const SHIPPED: &[(&str, &str)] = &[ #[test] fn shipped_components_show_no_fixed_english() { - let shown = [".tooltip(\"", ".child(\"", "SharedString::from(\""]; + let shown = [".tooltip(\"", ".child(\"", "SharedString::from(\"", "=> \""]; + // After an id: words, not a lowercase id. + let labels = ["), \"", "\", \"", "(\""]; 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())); - } + let checks = ["assert", "panic!", "log::", ".expect("]; + if checks.iter().any(|check| line.contains(check)) { + continue; + } + let literals = |calls: &[&str]| -> Vec<&str> { + (calls.iter()) + .flat_map(|call| { + line.match_indices(call) + .map(|(at, _)| &line[at + call.len()..]) + }) + .map(|rest| rest.split('"').next().unwrap_or(rest)) + .filter(|literal| literal.starts_with(|ch: char| ch.is_ascii_alphabetic())) + .collect() + }; + let key = |literal: &str| literal.contains('.') && !literal.contains(' '); + let words = + |literal: &str| literal.contains(' ') || literal.starts_with(char::is_uppercase); + let english = (literals(&shown).into_iter().filter(|literal| !key(literal))) + .chain( + literals(&labels) + .into_iter() + .filter(|literal| words(literal)), + ) + .chain( + line.trim() + .strip_prefix('"') + .and_then(|rest| rest.split('"').next()), + ) + .filter(|literal| { + literal.starts_with(char::is_uppercase) || !line.trim().starts_with('"') + }); + if english.count() > 0 { + found.push(format!("{path}:{}: {}", no + 1, line.trim())); } } } diff --git a/src/tables/builders.rs b/src/tables/builders.rs index 3e7a0d2..e969164 100644 --- a/src/tables/builders.rs +++ b/src/tables/builders.rs @@ -152,7 +152,7 @@ impl RenderOnce for FilterBuilder { let emit = Rc::new(emit); let tests: Vec = Test::ALL .iter() - .map(|test| Choice::new(test.label(), test.label())) + .map(|test| Choice::new(test.key(), i18n::text(cx, test.key(), &[]))) .collect(); let theme = cx.theme(); let (rules, any, id) = (self.rules.clone(), self.any, self.id.clone()); @@ -187,13 +187,13 @@ impl RenderOnce for FilterBuilder { .child( div().w(theme.label_width() * 0.75).child( Select::new((id.clone(), format!("test-{ix}")), tests.clone()) - .selected(rule.test.label()) + .selected(rule.test.key()) .size(ControlSize::Sm) .on_change(move |label, window, cx| { let mut next = for_test.clone(); next[ix].test = *Test::ALL .iter() - .find(|test| test.label() == label.as_ref()) + .find(|test| test.key() == label.as_ref()) .expect("a listed test"); test(next, any, window, cx) }), @@ -224,18 +224,21 @@ impl RenderOnce for FilterBuilder { .items_center() .justify_between() .child( - Button::new((self.id.clone(), "add"), "Add rule") - .icon(IconName::Plus) - .variant(ButtonVariant::Ghost) - .size(ControlSize::Sm) - .on_click(move |_, window, cx| { - let mut next = to_add.clone(); - next.push(FilterRule { - column: first.clone(), - ..FilterRule::default() - }); - add(next, any, window, cx) - }), + Button::new( + (self.id.clone(), "add"), + i18n::text(cx, "builder.add_rule", &[]), + ) + .icon(IconName::Plus) + .variant(ButtonVariant::Ghost) + .size(ControlSize::Sm) + .on_click(move |_, window, cx| { + let mut next = to_add.clone(); + next.push(FilterRule { + column: first.clone(), + ..FilterRule::default() + }); + add(next, any, window, cx) + }), ) .when(self.rules.len() > 1, |footer| { footer.child( @@ -243,8 +246,8 @@ impl RenderOnce for FilterBuilder { (self.id.clone(), "join"), if any { "any" } else { "all" }, ) - .segment("all", "Match all", None) - .segment("any", "Match any", None) + .segment("all", i18n::text(cx, "builder.all", &[]), None) + .segment("any", i18n::text(cx, "builder.any", &[]), None) .size(ControlSize::Sm) .on_change(move |value, window, cx| { join(to_join.clone(), value.as_ref() == "any", window, cx) @@ -365,8 +368,16 @@ impl RenderOnce for SortBuilder { (id.clone(), format!("sort-way-{ix}")), if sort.rising { "up" } else { "down" }, ) - .segment("up", "Ascending", Some(IconName::ArrowUp)) - .segment("down", "Descending", Some(IconName::ArrowDown)) + .segment( + "up", + i18n::text(cx, "builder.up", &[]), + Some(IconName::ArrowUp), + ) + .segment( + "down", + i18n::text(cx, "builder.down", &[]), + Some(IconName::ArrowDown), + ) .size(ControlSize::Sm) .on_change(move |way, window, cx| { let mut next = for_flip.clone(); @@ -395,18 +406,21 @@ impl RenderOnce for SortBuilder { .gap_1() .child(list) .children(unused.map(|column| { - Button::new((self.id.clone(), "add"), "Add sort") - .icon(IconName::Plus) - .variant(ButtonVariant::Ghost) - .size(ControlSize::Sm) - .on_click(move |_, window, cx| { - let mut next = add.1.clone(); - next.push(SortKey { - column: column.clone(), - rising: true, - }); - (add.0)(&next, window, cx) - }) + Button::new( + (self.id.clone(), "add"), + i18n::text(cx, "builder.add_sort", &[]), + ) + .icon(IconName::Plus) + .variant(ButtonVariant::Ghost) + .size(ControlSize::Sm) + .on_click(move |_, window, cx| { + let mut next = add.1.clone(); + next.push(SortKey { + column: column.clone(), + rising: true, + }); + (add.0)(&next, window, cx) + }) })) } } diff --git a/src/tables/rules.rs b/src/tables/rules.rs index 584e7e7..8adea6c 100644 --- a/src/tables/rules.rs +++ b/src/tables/rules.rs @@ -26,14 +26,15 @@ impl Test { Test::Empty, ]; - pub fn label(self) -> &'static str { + /// Its catalog key, which also names it. + pub fn key(self) -> &'static str { match self { - Self::Contains => "contains", - Self::Equals => "is", - Self::NotEquals => "is not", - Self::Above => "is above", - Self::Below => "is below", - Self::Empty => "is empty", + Self::Contains => "builder.test.contains", + Self::Equals => "builder.test.equals", + Self::NotEquals => "builder.test.not_equals", + Self::Above => "builder.test.above", + Self::Below => "builder.test.below", + Self::Empty => "builder.test.empty", } } diff --git a/src/tables/tests/chinese.rs b/src/tables/tests/chinese.rs new file mode 100644 index 0000000..02b6638 --- /dev/null +++ b/src/tables/tests/chinese.rs @@ -0,0 +1,62 @@ +//! The sort and filter builders read in Chinese under zh-CN. + +use gpui::{IntoElement, Render, TestAppContext, Window, div, prelude::*}; + +use crate::{ + i18n::{self, I18n}, + tables::{FilterBuilder, FilterRule, SortBuilder, SortKey, Test}, + theme::Theme, +}; + +struct Builders; + +impl Render for Builders { + fn render(&mut self, _: &mut Window, _: &mut gpui::Context) -> impl IntoElement { + let columns = [("a", "第一列"), ("b", "第二列")]; + let rule = FilterRule { + column: "a".into(), + ..FilterRule::default() + }; + let key = SortKey { + column: "a".into(), + rising: true, + }; + div() + .size_full() + .child(FilterBuilder::new("filter", columns).rules([rule.clone(), rule], true)) + .child(SortBuilder::new("sort", columns).keys([key])) + } +} + +#[gpui::test] +fn the_builders_read_in_chinese(cx: &mut TestAppContext) { + cx.update(|cx| { + Theme::init(cx); + cx.set_global(I18n::new("zh-CN").catalog("zh-CN", &[])); + }); + cx.update(|cx| { + for test in Test::ALL { + let shown = i18n::text(cx, test.key(), &[]); + assert!( + !shown.chars().any(|ch| ch.is_ascii_alphabetic()), + "{test:?}: {shown}" + ); + } + for key in [ + "builder.add_rule", + "builder.all", + "builder.any", + "builder.add_sort", + "builder.up", + "builder.down", + ] { + let shown = i18n::text(cx, key, &[]); + assert!( + !shown.chars().any(|ch| ch.is_ascii_alphabetic()), + "{key}: {shown}" + ); + } + }); + let (_, cx) = cx.add_window_view(|_, _| Builders); + cx.run_until_parked(); +} diff --git a/src/tables/tests/mod.rs b/src/tables/tests/mod.rs index 0fff119..4e8e6b9 100644 --- a/src/tables/tests/mod.rs +++ b/src/tables/tests/mod.rs @@ -9,6 +9,7 @@ use gpui::{ Styled, TestAppContext, VisualTestContext, Window, div, point, px, }; +mod chinese; mod clipboard; mod columns; mod stale;