#1145: macOS app menu follows the interface language

About FilmCraft and Join the ArtCraft Discord… in the macOS app menu take
the translated labels of the same Help menu commands and are updated on
a language switch. Hide, Hide Others and Quit get their text from the
catalogs instead of muda's English defaults and keep their native
actions. menus::NATIVE_APP_MENU lists these native-only labels together
with the Settings submenu, so the app and the tests use the same keys.

German rows for the three new keys; "Quit FilmCraft" is already
translated in es, ja, zh-cn and fr (the app.quit shortcut), the other
languages show English until they have translations. The German menu
test accepts and requires the native app menu labels.

Closes #1145

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Lukas Keller 2026-10-11 22:20:36 +02:00
parent 0800a8b0aa
commit 4ebcf3aa67
4 changed files with 38 additions and 12 deletions

View File

@ -4,7 +4,7 @@
use std::sync::mpsc::{Receiver, channel};
use filmcraft_ui_egui::FilmcraftApp;
use filmcraft_ui_egui::menus::{MENUS, MenuItem as Item, menu_items};
use filmcraft_ui_egui::menus::{MENUS, MenuItem as Item, NATIVE_APP_MENU, menu_items};
use muda::accelerator::Accelerator;
use muda::{Menu, MenuEvent, MenuItem, PredefinedMenuItem, Submenu};
@ -28,25 +28,39 @@ pub fn install(app: &FilmcraftApp, ctx: egui::Context) -> (Receiver<String>, Sho
let mut titles: Vec<(String, Submenu)> = Vec::new();
let bar = Menu::new();
let app_menu = Submenu::new("FilmCraft", true);
let [settings_label, hide_label, hide_others_label, quit_label] = NATIVE_APP_MENU;
// FilmCraft ▸ Settings ▸ <category> (Premiere's app-menu layout; General is Cmd+,)
let settings = Submenu::new(app.ui.language.tr("Settings"), true);
titles.push(("Settings".into(), settings.clone()));
let settings = Submenu::new(app.ui.language.tr(settings_label), true);
titles.push((settings_label.into(), settings.clone()));
for it in items.iter().filter(|i| i.id.starts_with("app.settings.")) {
let label = it.label.trim_end_matches('…').to_string();
let mi = MenuItem::with_id(it.id.clone(), label, true, it.shortcut.as_deref().and_then(accel));
native.push((it.id.clone(), mi.clone()));
let _ = settings.append(&mi);
}
// About and Discord repeat Help menu commands: same translated labels, updated with them
let label = |id: &str, english: &str| items.iter().find(|i| i.id == id).map_or_else(|| english.to_string(), |i| i.label.clone());
let about = MenuItem::with_id("app.about", label("app.about", "About FilmCraft"), true, None);
let discord = MenuItem::with_id("help.discord", label("help.discord", "Join the ArtCraft Discord…"), true, None);
native.push(("app.about".into(), about.clone()));
native.push(("help.discord".into(), discord.clone()));
// the predefined items keep their native actions; our text lets them follow the language
let predefined = [
(hide_label, PredefinedMenuItem::hide(Some(app.ui.language.tr(hide_label)))),
(hide_others_label, PredefinedMenuItem::hide_others(Some(app.ui.language.tr(hide_others_label)))),
(quit_label, PredefinedMenuItem::quit(Some(app.ui.language.tr(quit_label)))),
];
let [(_, hide), (_, hide_others), (_, quit)] = &predefined;
let _ = app_menu.append_items(&[
&MenuItem::with_id("app.about", "About FilmCraft", true, None),
&MenuItem::with_id("help.discord", "Join the ArtCraft Discord…", true, None),
&about,
&discord,
&PredefinedMenuItem::separator(),
&settings,
&PredefinedMenuItem::separator(),
&PredefinedMenuItem::hide(None),
&PredefinedMenuItem::hide_others(None),
hide,
hide_others,
&PredefinedMenuItem::separator(),
&PredefinedMenuItem::quit(None),
quit,
]);
let _ = bar.append(&app_menu);
for top in MENUS {
@ -115,6 +129,9 @@ pub fn install(app: &FilmcraftApp, ctx: egui::Context) -> (Receiver<String>, Sho
for (title, submenu) in &titles {
submenu.set_text(language.tr(title));
}
for (label, item) in &predefined {
item.set_text(language.tr(label));
}
for (id, mi) in &native {
if let Some(it) = items.iter().find(|i| &i.id == id) {
mi.set_text(&it.label);

View File

@ -6,7 +6,7 @@
# apps, text addresses the user informally ("du") and commands use the infinitive ("Projekt
# öffnen…"). Product, format and feature names (FilmCraft, ArtCraft, Lumetri, Essential Graphics,
# EDL, AAF…) keep their spelling. This catalog covers every menu, including the native macOS
# Settings menu; anything else is shown in English.
# app menu; anything else is shown in English.
#
# Terms: track = Spur, bin = Ablage, marker = Marker, playhead = Abspielposition, In/Out = In-/Out-
# Punkt, transition = Übergang, frame hold = Standbild, panel = Bedienfeld, workspace =
@ -180,6 +180,8 @@
Guide Templates Hilfslinienvorlagen
Help Hilfe
Hide All Caption Tracks Alle Untertitelspuren ausblenden
Hide FilmCraft FilmCraft ausblenden
Hide Others Andere ausblenden
High Quality Playback Wiedergabe in hoher Qualität
History Verlauf
Import From Importieren aus
@ -269,6 +271,7 @@
Properties Eigenschaften
Proxy Proxy
Purple Lila
Quit FilmCraft FilmCraft beenden
Reconnect Full Resolution Media… Medien in voller Auflösung neu verknüpfen…
Recover Unsaved Changes… Nicht gespeicherte Änderungen wiederherstellen…
Rectangle Rechteck

Can't render this file because it contains an unexpected character in line 6 and column 45.

View File

@ -931,7 +931,7 @@ mod tests {
}
/// German covers at least the core menus (the Brazilian Portuguese entries, the top-level menus
/// and the native macOS Settings menu); every entry must still be a menu label.
/// and the native macOS app menu); every entry must still be a menu label.
#[test]
fn german_entries_cover_the_original_menu_catalog() {
let app = crate::FilmcraftApp::new(filmcraft_engine::Session::default());
@ -939,17 +939,18 @@ mod tests {
let known = |text: &str| crate::menus::MENUS.contains(&text) || items.iter().any(|it| it.label == text || it.path.iter().any(|p| p == text));
let (entries, _) = catalog::parse_entries(include_str!("de.tsv"));
for (_, en, _) in entries {
assert!(known(&en) || en == "Settings", "not a menu label: {en}");
assert!(known(&en) || crate::menus::NATIVE_APP_MENU.contains(&en.as_str()), "not a menu label: {en}");
}
let (portuguese, _) = catalog::parse_entries(include_str!("pt-br.tsv"));
for (_, en, _) in portuguese {
assert!(Language::De.has(&en), "missing German menu label: {en}");
}
// "Clip" and "Audio" are the same word in German, so check for an entry, not a change
for en in crate::menus::MENUS.into_iter().chain(["Audio", "Settings"]) {
for en in crate::menus::MENUS.into_iter().chain(["Audio"]).chain(crate::menus::NATIVE_APP_MENU) {
assert!(Language::De.has(en), "untranslated German menu: {en}");
}
assert_eq!(Language::De.tr("Settings"), "Voreinstellungen");
assert_eq!(Language::De.tr("Quit FilmCraft"), "FilmCraft beenden");
}
/// French covers the whole interface (the Spanish catalog's entries and the native macOS

View File

@ -622,6 +622,11 @@ pub struct MenuItem {
pub const MENUS: [&str; 9] = ["File", "Edit", "Clip", "Sequence", "Markers", "Graphics and Titles", "View", "Window", "Help"];
/// Labels only the native macOS app menu shows, with no command in the menu tree behind them: the
/// Settings submenu and the predefined Hide, Hide Others and Quit items. They are translated through
/// the catalogs like menu labels (`apps/filmcraft/src/native_menu.rs`).
pub const NATIVE_APP_MENU: [&str; 4] = ["Settings", "Hide FilmCraft", "Hide Others", "Quit FilmCraft"];
pub fn menu_items(app: &FilmcraftApp) -> Vec<MenuItem> {
let mut v = menu_items_for(&app.session);
for it in &mut v {