i18n: the find box, sort and filter builders and password toggle in Chinese
CI / platforms (windows-latest) (push) Failing after 18s
CI / site (push) Successful in 23s
CI / crate (push) Failing after 13m36s
CI / platforms (ubuntu-24.04) (push) Canceled after 0s

The find counts, toggles, replace buttons, the builders' add, join and
direction labels, the filter tests and the password tooltip read the
builtin catalog. Test::label becomes Test::key, the choice's value; its
label is the catalog's. The shipped-copy scan checks button, segment and
match-arm text on every match, and zh-CN render tests cover both.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
ZacharyZhang-NYandClaude Opus 5.5 committed 2026-10-14 20:41:47 -04:00
1 parent 79e94f7e24
commit a4bf6965f9
11 files changed
+303 -79

No files matched your search

+1 -1
View File
@@ -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::<I18n>().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::<I18n>().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`).
+49 -28
View File
@@ -149,6 +149,26 @@ impl FindWidget {
}
}
/// The count shown, and whether it reads as a miss.
pub(crate) fn count(
matches: &Result<(Option<usize>, 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<OnBool>,
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))
}),
)
}))
}
+40
View File
@@ -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<TextInput>, Entity<TextInput>);
impl Render for Host {
fn render(&mut self, _: &mut Window, _: &mut gpui::Context<Self>) -> 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();
}
+1
View File
@@ -1,3 +1,4 @@
mod chinese;
mod history;
mod stale;
+9 -5
View File
@@ -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();
+50
View File
@@ -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",
+37 -7
View File
@@ -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()));
}
}
}
+45 -31
View File
@@ -152,7 +152,7 @@ impl RenderOnce for FilterBuilder {
let emit = Rc::new(emit);
let tests: Vec<Choice> = 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)
})
}))
}
}
+8 -7
View File
@@ -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",
}
}
+62
View File
@@ -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<Self>) -> 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();
}
+1
View File
@@ -9,6 +9,7 @@ use gpui::{
Styled, TestAppContext, VisualTestContext, Window, div, point, px,
};
mod chinese;
mod clipboard;
mod columns;
mod stale;