Start page: translate its buttons, links, headings and samples (#1479)

Checked locally on main merged with this: cargo check and the ui-egui tests, including the new i18n ones.
This commit is contained in:
rlartot authored and GitHub committed 2026-10-15 15:38:46 -04:00
1 parent b56648c055
commit 7934edcad4
10 files changed
+309 -27

No files matched your search

+29 -27
View File
@@ -362,7 +362,12 @@ pub fn show(app: &mut SolveApp, ui: &mut egui::Ui) {
ui.add_space(16.0);
ui.horizontal(|ui| {
let big = |ui: &mut egui::Ui, icon: &str, label: &str, primary: bool| -> bool {
let (r, resp) = ui.allocate_exact_size(vec2(170.0, 64.0), Sense::click());
// The label sets the width: a translation longer than the English one widens
// the button instead of running past its edge.
let ink_text = if primary { Color32::WHITE } else { t.text };
let galley = ui.painter().layout_no_wrap(label.to_owned(), egui::FontId::proportional(15.0), ink_text);
let width = (56.0 + galley.size().x + 20.0).max(170.0);
let (r, resp) = ui.allocate_exact_size(vec2(width, 64.0), Sense::click());
let fill = if primary {
t.accent
} else if resp.hovered() {
@@ -380,20 +385,14 @@ pub fn show(app: &mut SolveApp, ui: &mut egui::Ui) {
t.icon_fill,
ink,
);
ui.painter().text(
r.left_center() + vec2(56.0, 0.0),
egui::Align2::LEFT_CENTER,
label,
egui::FontId::proportional(15.0),
if primary { Color32::WHITE } else { t.text },
);
ui.painter().galley(r.left_center() + vec2(56.0, -galley.size().y / 2.0), galley, ink_text);
resp.clicked()
};
if big(ui, "new", "New Design", true) {
if big(ui, "new", app.tr("ui.new_design"), true) {
action = Some(Box::new(|app: &mut SolveApp| crate::documents::new_design(app)));
}
ui.add_space(10.0);
if big(ui, "open", "Open…", false) {
if big(ui, "open", app.tr("home.open"), false) {
action = Some(Box::new(|app: &mut SolveApp| {
app.pick_open(|app, path| {
if let Some(path) = path {
@@ -406,19 +405,19 @@ pub fn show(app: &mut SolveApp, ui: &mut egui::Ui) {
});
ui.add_space(24.0);
ui.horizontal_wrapped(|ui| {
ui.hyperlink_to("Documentation", crate::help::DOCS_URL);
ui.hyperlink_to("Report an Issue", "https://github.com/bherbruck/solidcraft/issues/new");
ui.hyperlink_to("What's new", "https://github.com/bherbruck/solidcraft/releases/latest");
if ui.link("Keyboard Shortcuts").clicked() {
ui.hyperlink_to(app.tr("home.documentation"), crate::help::DOCS_URL);
ui.hyperlink_to(app.tr("home.report_issue"), "https://github.com/bherbruck/solidcraft/issues/new");
ui.hyperlink_to(app.tr("home.whats_new"), "https://github.com/bherbruck/solidcraft/releases/latest");
if ui.link(app.tr("ui.keyboard_shortcuts")).clicked() {
app.keymap.open = true;
app.keymap.read_only = true;
}
if ui.link("Preferences").clicked() {
if ui.link(app.tr("ui.preferences")).clicked() {
crate::prefs::open_at(app, 0);
}
});
ui.add_space(16.0);
section(ui, "Recent");
section(ui, app.tr("home.recent"));
if recent.is_empty() {
ui.label(RichText::new(crate::i18n::tr_label("Designs you open or save appear here.")).color(t.text_dim));
} else {
@@ -428,26 +427,27 @@ pub fn show(app: &mut SolveApp, ui: &mut egui::Ui) {
let name = path.file_stem().map(|x| x.to_string_lossy().to_string()).unwrap_or_else(|| p.clone());
let missing = !solidcraft_engine::io::vfs::exists(p);
let mut sub = if missing {
"missing".to_string()
app.tr("home.missing").to_string()
} else if p.starts_with("/opfs/") {
// The web build keeps designs in the browser's own storage.
"in this browser".to_string()
app.tr("home.in_this_browser").to_string()
} else {
p.clone()
};
if let Some(time) = app.home.opened_at.get(p) {
let now = web_time::SystemTime::now().duration_since(web_time::UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(*time);
let seconds = now.saturating_sub(*time);
let ago = if seconds < 60 {
"just now".to_string()
let opened = if seconds < 60 {
app.tr("home.opened_just_now").to_string()
} else if seconds < 3600 {
format!("{} min ago", seconds / 60)
crate::i18n::format("home.opened_minutes_ago", &[("count", &(seconds / 60).to_string())])
} else if seconds < 86400 {
format!("{} h ago", seconds / 3600)
crate::i18n::format("home.opened_hours_ago", &[("count", &(seconds / 3600).to_string())])
} else {
format!("{} days ago", seconds / 86400)
crate::i18n::format("home.opened_days_ago", &[("count", &(seconds / 86400).to_string())])
};
sub.push_str(&format!("\nOpened {ago}"));
sub.push('\n');
sub.push_str(&opened);
}
let response = card(ui, Pic::Ready(app.home.thumbs.get(p.as_str()).and_then(Option::as_ref)), &name, &sub, missing)
.on_hover_text(p.as_str());
@@ -472,17 +472,19 @@ pub fn show(app: &mut SolveApp, ui: &mut egui::Ui) {
});
}
ui.add_space(24.0);
section(ui, "Samples");
section(ui, app.tr("home.samples"));
grid(ui, samples.len(), |ui, i| {
let Some(s) = samples.get(i) else { return };
let key = format!("sample:{i}");
let busy = app.home.wanted == Some(i);
let sub = if busy { "opening…" } else { s.about };
// The engine's sample table holds English text; the opened design keeps it as its name.
let about = app.tr_label(s.about);
let sub = if busy { app.tr("home.opening") } else { about };
let tex = match app.home.thumbs.get(&key) {
Some(t) => Pic::Ready(t.as_ref()),
None => Pic::Icon("box"),
};
if card(ui, tex, s.name, sub, false).on_hover_text(s.about).clicked() {
if card(ui, tex, app.tr_label(s.name), sub, false).on_hover_text(about).clicked() {
action = Some(Box::new(move |app: &mut SolveApp| open_sample(app, i)));
}
});
+12
View File
@@ -267,6 +267,17 @@ mod tests {
);
}
/// The start page shows each built-in sample's name and description through `label()`, so a
/// sample added to the engine without English catalog entries would stay English everywhere.
#[test]
fn every_sample_name_and_description_is_in_the_english_catalog() {
let texts = english_texts();
let samples = solidcraft_engine::sample::samples();
let missing: Vec<&str> = samples.iter().flat_map(|s| [s.name, s.about]).filter(|text| !texts.contains(text)).collect();
assert!(missing.is_empty(), "sample texts absent from en.json: {missing:?}");
assert_eq!(Locale::for_language("ja").label("Sample Plate"), "サンプル プレート");
}
/// The whole engine registry now reaches the catalogs. Sample command labels from areas that
/// had no entry at all before (sheet metal, inspect, joints, motion) must resolve to their
/// translation rather than echo the English source text — `label()` returns its argument when
@@ -317,6 +328,7 @@ mod tests {
include_str!("browser.rs"),
include_str!("sketch_palette.rs"),
include_str!("design_properties.rs"),
include_str!("home.rs"),
] {
for call in [".tr(\"", "i18n::tr(\"", "i18n::format(\""] {
for tail in source.split(call).skip(1) {
+93
View File
@@ -84,6 +84,99 @@ fn window_chrome_renders_in_the_active_language() {
}
}
/// The start page's two big buttons had a fixed width, so a translation longer than the English
/// label ran past the edge ("Nouvelle conception"). Every registered language's label must stay
/// inside its button.
#[test]
fn home_page_buttons_fit_their_translated_labels() {
fn collect(shape: &egui::Shape, rects: &mut Vec<egui::Rect>, texts: &mut Vec<(String, egui::Rect)>) {
match shape {
egui::Shape::Rect(r) => rects.push(r.rect),
egui::Shape::Text(t) => texts.push((t.galley.job.text.clone(), egui::Rect::from_min_size(t.pos, t.galley.size()))),
egui::Shape::Vec(shapes) => shapes.iter().for_each(|s| collect(s, rects, texts)),
_ => {}
}
}
for catalog in crate::i18n::LOCALES {
let mut h = Harness::new();
h.app.preferences.language = catalog.id.into();
let rev = h.app.session.revision;
h.app.home.show(rev);
h.frames(2);
let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1600.0, 1000.0));
let mut out = h.ctx.run_ui(egui::RawInput { screen_rect: Some(screen), ..Default::default() }, |ui| h.app.ui(ui));
out.textures_delta.clear();
let (mut rects, mut texts) = (Vec::new(), Vec::new());
out.shapes.iter().for_each(|shape| collect(&shape.shape, &mut rects, &mut texts));
let locale = crate::i18n::Locale::for_language(catalog.id);
for id in ["ui.new_design", "home.open"] {
let label = locale.text(id);
let (_, text) = texts.iter().find(|(t, _)| t == label).unwrap_or_else(|| panic!("{}: {label:?} is not painted", catalog.id));
let button = rects
.iter()
.filter(|r| (r.height() - 64.0).abs() < 1.0 && r.contains(text.left_center()))
.min_by(|a, b| a.area().total_cmp(&b.area()))
.unwrap_or_else(|| panic!("{}: no button around {label:?}", catalog.id));
assert!(text.right() <= button.right(), "{}: {label:?} ends at {} past its button's edge {}", catalog.id, text.right(), button.right());
}
}
}
/// The start page is the first thing a user sees. Its buttons, links, headings and cards were
/// English literals, mostly painted rather than laid out as widgets, so check the text actually
/// painted in each complete language: a broken message id falls back to English silently.
#[test]
fn home_page_renders_in_the_active_language() {
fn painted(shape: &egui::Shape, out: &mut Vec<String>) {
match shape {
egui::Shape::Text(text) => out.push(text.galley.job.text.clone()),
egui::Shape::Vec(shapes) => shapes.iter().for_each(|s| painted(s, out)),
_ => {}
}
}
let cases = [
(
"ja",
[
"新規デザイン",
"開く…",
"ドキュメント",
"問題を報告",
"新機能",
"キーボードショートカット",
"環境設定",
"最近使用した項目",
"サンプル",
"サンプル プレート",
],
"見つかりません\n2 時間前に開きました",
),
("zh", ["新建设计", "打开…", "文档", "报告问题", "新功能", "键盘快捷键", "首选项", "最近使用", "示例", "示例板"], "文件不存在\n2 小时前打开"),
];
for (lang, words, missing_recent) in cases {
let mut h = Harness::new();
h.app.preferences.language = lang.into();
let gone = h.dir.join("gone.solidcraft").to_string_lossy().into_owned();
h.app.home.add_recent(&gone);
let now = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_secs();
h.app.home.opened_at.insert(gone, now - 2 * 3600);
let rev = h.app.session.revision;
h.app.home.show(rev);
h.frames(2);
let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1600.0, 1000.0));
let mut out = h.ctx.run_ui(egui::RawInput { screen_rect: Some(screen), ..Default::default() }, |ui| h.app.ui(ui));
out.textures_delta.clear();
let mut texts = Vec::new();
out.shapes.iter().for_each(|shape| painted(&shape.shape, &mut texts));
for word in words.into_iter().chain([missing_recent]) {
assert!(texts.iter().any(|t| t == word), "{lang}: {word:?} must be painted, got {texts:?}");
}
for english in ["New Design", "Open…", "Documentation", "Report an Issue", "What's new", "Recent", "Samples", "Sample Plate"] {
assert!(!texts.iter().any(|t| t == english), "{lang}: {english:?} is still painted in English");
}
}
}
/// The command palette's hint embeds a JSON example, so its braces must not be mistaken for a
/// placeholder name by the catalog's parser, and the example must survive interpolation whole.
#[test]