diff --git a/assets/locales/de.json b/assets/locales/de.json index 03b43cc3..f0b16b46 100644 --- a/assets/locales/de.json +++ b/assets/locales/de.json @@ -26,6 +26,31 @@ "dialog.selection_guidance": "Wählen Sie eine Eingabe aus, um sie zu ändern. Klicken Sie auf ausgewählte Objekte, um sie zu entfernen; Strg/Cmd-Klick schaltet sie ebenfalls um. Prüfen Sie die Vorschau und drücken Sie dann OK.", "document.save_changes": "Änderungen in {name} speichern?", "document.unsaved_changes": "{name} enthält ungespeicherte Änderungen. Wenn Sie nicht speichern, gehen sie verloren.", + "home.documentation": "Dokumentation", + "home.in_this_browser": "in diesem Browser", + "home.missing": "fehlt", + "home.open": "Öffnen…", + "home.opened_days_ago": "Vor {count} Tag(en) geöffnet", + "home.opened_hours_ago": "Vor {count} Std. geöffnet", + "home.opened_just_now": "Gerade eben geöffnet", + "home.opened_minutes_ago": "Vor {count} Min. geöffnet", + "home.opening": "wird geöffnet…", + "home.recent": "Zuletzt verwendet", + "home.report_issue": "Problem melden", + "home.samples": "Beispiele", + "home.whats_new": "Neuigkeiten", + "sample.bracket.about": "Grundflansch mit Kantenflanschen und einer Umkantung nach einer 2-mm-Blechregel", + "sample.bracket.name": "Blechwinkel", + "sample.enclosure.about": "Ausgehöhlter Block mit Lippe, Schraubenerhöhungen, Auflage und Rastverbindung", + "sample.enclosure.name": "Kunststoffgehäuse", + "sample.flange.about": "Nabe und Flansch mit einem parametergesteuerten radialen Muster", + "sample.flange.name": "Schraubflansch", + "sample.hinge.about": "Zwei Komponenten und ein Drehgelenk, auf 60° angetrieben", + "sample.hinge.name": "Scharnierbaugruppe", + "sample.plate.about": "Parametrische Platte mit Vollrundungen, Bohrungen und einer aufgebohrten Erhöhung", + "sample.plate.name": "Beispielplatte", + "sample.vents.about": "Langlöcher in einem Rechteckmuster: Zeilen, Spalten und Abstand sind Parameter", + "sample.vents.name": "Lüftungsplatte", "settings.msaa_applies_on_restart": "Die Kantenglättung wird beim nächsten Start von SolidCraft übernommen", "settings.msaa_reset": "{setting}: {requested}× wird von diesem Grafikgerät nicht unterstützt; jetzt {reset}× (unterstützt: {supported}).", "settings.msaa_unsupported": "Von diesem Grafikgerät nicht unterstützt", diff --git a/assets/locales/en.json b/assets/locales/en.json index bc33cfe1..537634b5 100644 --- a/assets/locales/en.json +++ b/assets/locales/en.json @@ -306,6 +306,31 @@ "dialog.selection_guidance": "Select an input to change it. Click selected objects to remove them; Ctrl/Cmd-click also toggles them. Review the preview, then press OK.", "document.save_changes": "Save changes to {name}?", "document.unsaved_changes": "{name} has unsaved changes. If you don't save, they are lost.", + "home.documentation": "Documentation", + "home.in_this_browser": "in this browser", + "home.missing": "missing", + "home.open": "Open…", + "home.opened_days_ago": "Opened {count} days ago", + "home.opened_hours_ago": "Opened {count} h ago", + "home.opened_just_now": "Opened just now", + "home.opened_minutes_ago": "Opened {count} min ago", + "home.opening": "opening…", + "home.recent": "Recent", + "home.report_issue": "Report an Issue", + "home.samples": "Samples", + "home.whats_new": "What's new", + "sample.bracket.about": "Base flange with edge flanges and a hem on a 2 mm rule", + "sample.bracket.name": "Sheet Metal Bracket", + "sample.enclosure.about": "Shelled box with a lip, screw bosses, a rest and a snap fit", + "sample.enclosure.name": "Plastic Enclosure", + "sample.flange.about": "Hub and flange with a circular pattern driven by a parameter", + "sample.flange.name": "Bolted Flange", + "sample.hinge.about": "Two components and a revolute joint, driven to 60°", + "sample.hinge.name": "Hinge Assembly", + "sample.plate.about": "Parametric plate with fillets, holes and a bored boss", + "sample.plate.name": "Sample Plate", + "sample.vents.about": "Slots in a rectangular pattern: rows, columns and pitch are parameters", + "sample.vents.name": "Vent Plate", "settings.msaa_applies_on_restart": "Anti-aliasing applies the next time SolidCraft starts", "settings.msaa_reset": "{setting}: {requested}× isn't supported by this graphics device; it is now {reset}× (supported: {supported}).", "settings.msaa_unsupported": "Not supported by this graphics device", diff --git a/assets/locales/fr.json b/assets/locales/fr.json index 2a09363d..029212ff 100644 --- a/assets/locales/fr.json +++ b/assets/locales/fr.json @@ -306,6 +306,31 @@ "dialog.selection_guidance": "Sélectionner une entrée pour la modifier. Cliquer sur un objet sélectionné pour le retirer ; Ctrl/Cmd-clic le bascule aussi. Vérifier l'aperçu, puis appuyer sur OK.", "document.save_changes": "Enregistrer les modifications de {name} ?", "document.unsaved_changes": "{name} contient des modifications non enregistrées. Si vous n'enregistrez pas, elles seront perdues.", + "home.documentation": "Documentation", + "home.in_this_browser": "dans ce navigateur", + "home.missing": "introuvable", + "home.open": "Ouvrir…", + "home.opened_days_ago": "Ouvert il y a {count} j", + "home.opened_hours_ago": "Ouvert il y a {count} h", + "home.opened_just_now": "Ouvert à l'instant", + "home.opened_minutes_ago": "Ouvert il y a {count} min", + "home.opening": "ouverture…", + "home.recent": "Récents", + "home.report_issue": "Signaler un problème", + "home.samples": "Exemples", + "home.whats_new": "Nouveautés", + "sample.bracket.about": "Tôle de base avec bords tombés et un ourlet, selon une règle de 2 mm", + "sample.bracket.name": "Équerre en tôle", + "sample.enclosure.about": "Boîte en coque avec lèvre, bossages de vis, appui et clip", + "sample.enclosure.name": "Boîtier plastique", + "sample.flange.about": "Moyeu et bride avec un réseau circulaire piloté par un paramètre", + "sample.flange.name": "Bride boulonnée", + "sample.hinge.about": "Deux composants et une liaison pivot, entraînée à 60°", + "sample.hinge.name": "Assemblage de charnière", + "sample.plate.about": "Plaque paramétrique avec congés, perçages et un bossage alésé", + "sample.plate.name": "Plaque d'exemple", + "sample.vents.about": "Rainures en réseau rectangulaire : lignes, colonnes et pas sont des paramètres", + "sample.vents.name": "Plaque d'aération", "settings.msaa_applies_on_restart": "L'anticrénelage s'appliquera au prochain démarrage de SolidCraft", "settings.msaa_reset": "{setting} : {requested}× n'est pas pris en charge par ce périphérique graphique ; la valeur est désormais {reset}× (pris en charge : {supported}).", "settings.msaa_unsupported": "Non pris en charge par ce périphérique graphique", diff --git a/assets/locales/ja.json b/assets/locales/ja.json index bfd2ef88..2eee9b3b 100644 --- a/assets/locales/ja.json +++ b/assets/locales/ja.json @@ -306,6 +306,31 @@ "dialog.selection_guidance": "入力欄を選んで変更します。選択済みのオブジェクトをクリックすると解除できます。Ctrl/Cmd を押しながらクリックしても切り替えられます。プレビューを確認してから OK を押してください。", "document.save_changes": "{name} への変更を保存しますか?", "document.unsaved_changes": "{name} には保存されていない変更があります。保存しない場合、変更内容は失われます。", + "home.documentation": "ドキュメント", + "home.in_this_browser": "このブラウザ内", + "home.missing": "見つかりません", + "home.open": "開く…", + "home.opened_days_ago": "{count} 日前に開きました", + "home.opened_hours_ago": "{count} 時間前に開きました", + "home.opened_just_now": "たった今開きました", + "home.opened_minutes_ago": "{count} 分前に開きました", + "home.opening": "開いています…", + "home.recent": "最近使用した項目", + "home.report_issue": "問題を報告", + "home.samples": "サンプル", + "home.whats_new": "新機能", + "sample.bracket.about": "2 mm の板金ルールによるベース フランジ、エッジ フランジ、ヘミング", + "sample.bracket.name": "板金ブラケット", + "sample.enclosure.about": "リップ、ねじボス、レスト、スナップ フィットを備えたシェル化した直方体", + "sample.enclosure.name": "プラスチック筐体", + "sample.flange.about": "パラメータで制御する円形パターンを持つハブとフランジ", + "sample.flange.name": "ボルト締めフランジ", + "sample.hinge.about": "2 つのコンポーネントと回転ジョイント、60° まで駆動", + "sample.hinge.name": "ヒンジ アセンブリ", + "sample.plate.about": "フィレット、穴、穴あきボスを備えたパラメトリック プレート", + "sample.plate.name": "サンプル プレート", + "sample.vents.about": "矩形パターンに並んだスロット: 行数、列数、ピッチはパラメータ", + "sample.vents.name": "通気プレート", "settings.msaa_applies_on_restart": "アンチエイリアスは次回 SolidCraft を起動したときに適用されます", "settings.msaa_reset": "{setting}: {requested}× はこのグラフィックスデバイスではサポートされていないため、{reset}× に変更しました(サポート: {supported})。", "settings.msaa_unsupported": "このグラフィックスデバイスではサポートされていません", diff --git a/assets/locales/ko.json b/assets/locales/ko.json index 2740f32a..25c9e393 100644 --- a/assets/locales/ko.json +++ b/assets/locales/ko.json @@ -26,6 +26,31 @@ "dialog.selection_guidance": "변경할 입력을 선택하세요. 선택한 객체를 클릭하면 선택이 해제되며, Ctrl/Cmd를 누른 채 클릭해도 전환됩니다. 미리 보기를 확인한 다음 확인을 누르세요.", "document.save_changes": "{name}의 변경 사항을 저장하시겠습니까?", "document.unsaved_changes": "{name}에 저장되지 않은 변경 사항이 있습니다. 저장하지 않으면 변경 사항이 사라집니다.", + "home.documentation": "문서", + "home.in_this_browser": "이 브라우저에 저장됨", + "home.missing": "찾을 수 없음", + "home.open": "열기…", + "home.opened_days_ago": "{count}일 전에 열림", + "home.opened_hours_ago": "{count}시간 전에 열림", + "home.opened_just_now": "방금 열림", + "home.opened_minutes_ago": "{count}분 전에 열림", + "home.opening": "여는 중…", + "home.recent": "최근 항목", + "home.report_issue": "문제 신고", + "home.samples": "샘플", + "home.whats_new": "새로운 기능", + "sample.bracket.about": "2 mm 판금 규칙을 적용한 베이스 플랜지, 에지 플랜지, 헴", + "sample.bracket.name": "판금 브래킷", + "sample.enclosure.about": "리프, 나사 보스, 레스트, 스냅핏이 있는 쉘 처리된 박스", + "sample.enclosure.name": "플라스틱 인클로저", + "sample.flange.about": "매개변수로 제어되는 원형 패턴이 있는 허브와 플랜지", + "sample.flange.name": "볼트 플랜지", + "sample.hinge.about": "두 구성 요소와 회전 관절, 60°까지 구동", + "sample.hinge.name": "힌지 어셈블리", + "sample.plate.about": "필렛, 구멍, 보어 가공된 보스가 있는 매개변수 기반 플레이트", + "sample.plate.name": "샘플 플레이트", + "sample.vents.about": "직사각형 패턴의 슬롯: 행, 열, 피치가 매개변수", + "sample.vents.name": "통풍 플레이트", "settings.msaa_applies_on_restart": "안티앨리어싱은 다음에 SolidCraft를 시작할 때 적용됩니다", "settings.msaa_reset": "{setting}: {requested}×는 이 그래픽 장치에서 지원되지 않아 {reset}×로 변경했습니다(지원: {supported}).", "settings.msaa_unsupported": "이 그래픽 장치에서 지원되지 않음", diff --git a/assets/locales/ru.json b/assets/locales/ru.json index 8894d50b..9ebf530a 100644 --- a/assets/locales/ru.json +++ b/assets/locales/ru.json @@ -291,6 +291,31 @@ "dialog.selection_guidance": "Щёлкните поле ввода, чтобы изменить его. Щелчок по выделенному объекту убирает его; Ctrl/Cmd+щелчок тоже переключает. Проверьте предпросмотр и нажмите OK.", "document.save_changes": "Сохранить изменения в {name}?", "document.unsaved_changes": "В {name} есть несохранённые изменения. Если их не сохранить, они будут потеряны.", + "home.documentation": "Документация", + "home.in_this_browser": "в этом браузере", + "home.missing": "отсутствует", + "home.open": "Открыть…", + "home.opened_days_ago": "Открыт {count} дн. назад", + "home.opened_hours_ago": "Открыт {count} ч назад", + "home.opened_just_now": "Открыт только что", + "home.opened_minutes_ago": "Открыт {count} мин назад", + "home.opening": "открывается…", + "home.recent": "Недавние", + "home.report_issue": "Сообщить о проблеме", + "home.samples": "Примеры", + "home.whats_new": "Что нового", + "sample.bracket.about": "Базовый фланец с краевыми фланцами и кромкой по правилу листового металла 2 мм", + "sample.bracket.name": "Кронштейн из листового металла", + "sample.enclosure.about": "Тонкостенный бокс с выступом, бобышками под винты, опорой и защёлкой", + "sample.enclosure.name": "Пластиковый корпус", + "sample.flange.about": "Ступица и фланец с круговым массивом, управляемым параметром", + "sample.flange.name": "Болтовой фланец", + "sample.hinge.about": "Два компонента и вращательное сопряжение, повёрнутое на 60°", + "sample.hinge.name": "Сборка шарнира", + "sample.plate.about": "Параметрическая пластина со скруглениями, отверстиями и расточенной бобышкой", + "sample.plate.name": "Образец пластины", + "sample.vents.about": "Пазы в прямоугольном массиве: число рядов, столбцов и шаг заданы параметрами", + "sample.vents.name": "Вентиляционная пластина", "settings.msaa_applies_on_restart": "Сглаживание вступит в силу при следующем запуске SolidCraft", "settings.msaa_reset": "{setting}: {requested}× не поддерживается этим графическим устройством; теперь {reset}× (поддерживается: {supported}).", "settings.msaa_unsupported": "Не поддерживается этим графическим устройством", diff --git a/assets/locales/zh.json b/assets/locales/zh.json index 4bd604ce..64799acc 100644 --- a/assets/locales/zh.json +++ b/assets/locales/zh.json @@ -306,6 +306,31 @@ "dialog.selection_guidance": "选择输入项进行修改。点击已选对象可取消选择;按住 Ctrl/Cmd 点击也可切换选择。检查预览后,再按“确定”。", "document.save_changes": "保存对 {name} 的更改?", "document.unsaved_changes": "{name} 有未保存的更改。如果不保存,将丢失这些更改。", + "home.documentation": "文档", + "home.in_this_browser": "在此浏览器中", + "home.missing": "文件不存在", + "home.open": "打开…", + "home.opened_days_ago": "{count} 天前打开", + "home.opened_hours_ago": "{count} 小时前打开", + "home.opened_just_now": "刚刚打开", + "home.opened_minutes_ago": "{count} 分钟前打开", + "home.opening": "正在打开…", + "home.recent": "最近使用", + "home.report_issue": "报告问题", + "home.samples": "示例", + "home.whats_new": "新功能", + "sample.bracket.about": "基于 2 mm 钣金规则的基础法兰、边线法兰和卷边", + "sample.bracket.name": "钣金支架", + "sample.enclosure.about": "带凸缘、螺钉凸台、垫块和卡扣的抽壳长方体", + "sample.enclosure.name": "塑料外壳", + "sample.flange.about": "带有由参数驱动的圆形阵列的轮毂和法兰", + "sample.flange.name": "螺栓法兰", + "sample.hinge.about": "两个组件和一个旋转接头,驱动到 60°", + "sample.hinge.name": "铰链装配体", + "sample.plate.about": "带圆角、孔和镗孔凸台的参数化板", + "sample.plate.name": "示例板", + "sample.vents.about": "按矩形阵列排列的槽:行数、列数和间距均为参数", + "sample.vents.name": "通风板", "settings.msaa_applies_on_restart": "抗锯齿将在下次启动 SolidCraft 时生效", "settings.msaa_reset": "{setting}: {requested}× 不受此图形设备支持;已重置为 {reset}× (支持: {supported})。", "settings.msaa_unsupported": "此图形设备不支持", diff --git a/crates/ui-egui/src/home.rs b/crates/ui-egui/src/home.rs index e3858ea9..b2b1d237 100644 --- a/crates/ui-egui/src/home.rs +++ b/crates/ui-egui/src/home.rs @@ -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))); } }); diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs index 28c548c0..8d802745 100644 --- a/crates/ui-egui/src/i18n.rs +++ b/crates/ui-egui/src/i18n.rs @@ -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) { diff --git a/crates/ui-egui/src/i18n_ui_tests.rs b/crates/ui-egui/src/i18n_ui_tests.rs index 6a477010..039b1c5b 100644 --- a/crates/ui-egui/src/i18n_ui_tests.rs +++ b/crates/ui-egui/src/i18n_ui_tests.rs @@ -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, 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) { + 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]