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:
1 parent
b56648c055
commit
7934edcad4
10 files changed
+309
-27
No files matched your search
+29
-27
@@ -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)));
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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]
|
||||
|
||||
Reference in new issue
Block a user