i18n: six new languages; packs become fully data-driven
New complete language packs (431 keys each, key-aligned with en-US): French, German, Russian, Japanese, Spanish, Portuguese — contributed via the translation subagent, reviewed for key parity and genuine translations. Runtime discovery replaces compiled-in packs: include_str! and the Language enum are gone; every <code>.yaml found in the searched directories registers a language, so adding one needs neither a rebuild nor a code change. Each pack names itself via its "language.name" key (the endonym), which the pickers display. The menu bar language submenu is built from the discovered packs (menu ids in the new LANG_ITEM_BASE range, dispatched before the registry), the preferences combo enumerates them the same way, and switching goes through set_language_code. The C++-style LangZh/LangEn registry actions are removed (menu ids 303/304 stay reserved). pack_dirs gains the system-install layout <exe>/../share/oak/i18n, and en.yaml is renamed en-US.yaml so the discovered code matches the fallback code. Test hardening (a Windows/Linux CI flake chain exposed by this work): the two language-test mutexes are one shared poison-tolerant mutex; every test that flips the process language restores en-US before releasing it; tests that match menus by localized labels take the lock and pin en-US first.
This commit is contained in:
+15
-8
@@ -162,8 +162,11 @@ define_actions! {
|
||||
FullScreenViewer { cpp: "fullscreenviewer", i18n: "menu.view.full_screen_viewer", keys: [], route: FocusedPanel, menu_id: 1044 };
|
||||
ThemeDark { cpp: "themedark", i18n: "menu.view.theme.dark", keys: [], route: Global, menu_id: 301 };
|
||||
ThemeLight { cpp: "themelight", i18n: "menu.view.theme.light", keys: [], route: Global, menu_id: 302 };
|
||||
LangZh { cpp: "langzh", i18n: "menu.view.language.zh", keys: [], route: Global, menu_id: 303 };
|
||||
LangEn { cpp: "langen", i18n: "menu.view.language.en", keys: [], route: Global, menu_id: 304 };
|
||||
// Language switching is deliberately NOT a registry action: the
|
||||
// language menu is built from the discovered packs (menu ids in the
|
||||
// LANG_ITEM_BASE range, see menus::shared), so a new language is a new
|
||||
// YAML file, not a code change. The C++ `langzh`/`langen` action slots
|
||||
// (menu ids 303/304) stay reserved.
|
||||
|
||||
// --- Playback -----------------------------------------------------------
|
||||
GoToStart { cpp: "gotostart", i18n: "menu.playback.to_start", keys: ["home"], route: FocusedPanel, menu_id: 404 };
|
||||
@@ -820,8 +823,8 @@ mod tests {
|
||||
/// is meant to drive the menus).
|
||||
#[test]
|
||||
fn every_action_appears_in_the_menu_tree() {
|
||||
let _guard = crate::i18n::lang_test_lock().lock().unwrap();
|
||||
crate::i18n::set_language(crate::i18n::Language::EnUs);
|
||||
let _guard = crate::i18n::lang_test_lock().lock().unwrap_or_else(|e| e.into_inner());
|
||||
crate::i18n::set_language_code("en-US");
|
||||
|
||||
fn collect(menu: &gpui_widgets::menu::Menu, out: &mut Vec<usize>) {
|
||||
for item in &menu.items {
|
||||
@@ -866,18 +869,22 @@ mod tests {
|
||||
#[test]
|
||||
fn every_i18n_key_exists_in_both_languages() {
|
||||
for entry in REGISTRY {
|
||||
for language in [crate::i18n::Language::EnUs, crate::i18n::Language::ZhCN] {
|
||||
let _guard = crate::i18n::lang_test_lock().lock().unwrap();
|
||||
crate::i18n::set_language(language);
|
||||
for code in ["en-US", "zh-CN"] {
|
||||
let _guard = crate::i18n::lang_test_lock().lock().unwrap_or_else(|e| e.into_inner());
|
||||
crate::i18n::set_language_code(code);
|
||||
let value = crate::i18n::tr(entry.i18n_key);
|
||||
assert_ne!(
|
||||
value, entry.i18n_key,
|
||||
"i18n key {} ({language:?}) is missing for action {}",
|
||||
"i18n key {} ({code}) is missing for action {}",
|
||||
entry.i18n_key, entry.cpp_id
|
||||
);
|
||||
assert!(!value.is_empty());
|
||||
}
|
||||
}
|
||||
// The loop leaves zh-CN active; restore the default so lock-free
|
||||
// tests building English menus are not fooled mid-assert.
|
||||
let _guard = crate::i18n::lang_test_lock().lock().unwrap_or_else(|e| e.into_inner());
|
||||
crate::i18n::set_language_code("en-US");
|
||||
}
|
||||
|
||||
/// The shortcut display formatter renders the documented labels for a
|
||||
|
||||
+70
-60
@@ -95,8 +95,6 @@ pub(crate) mod menu_ids {
|
||||
pub const RIPPLE_DELETE: usize = ActionId::RippleDelete.menu_id();
|
||||
|
||||
pub const THEME_DARK: usize = ActionId::ThemeDark.menu_id();
|
||||
pub const LANG_ZH: usize = ActionId::LangZh.menu_id();
|
||||
pub const LANG_EN: usize = ActionId::LangEn.menu_id();
|
||||
pub const PREFERENCES: usize = ActionId::Preferences.menu_id();
|
||||
|
||||
pub const FOCUS_PROJECT: usize = ActionId::FocusProject.menu_id();
|
||||
@@ -759,10 +757,20 @@ impl<E: AppEngine> OakApp<E> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Routes a menu click: resolves the item id to its registry action and
|
||||
/// dispatches it through the same path the keyboard shortcuts use, so a
|
||||
/// menu click and a key press can never diverge.
|
||||
/// Routes a menu click: the dynamic language items (one per discovered
|
||||
/// pack, above the registry id range) switch the language directly;
|
||||
/// everything else resolves to its registry action and dispatches
|
||||
/// through the same path the keyboard shortcuts use, so a menu click
|
||||
/// and a key press can never diverge.
|
||||
fn on_menu(&mut self, item: usize, cx: &mut Context<Self>) {
|
||||
if let Some(index) = crate::menus::shared::language_item_index(item) {
|
||||
let languages = crate::i18n::available_languages();
|
||||
if let Some(code) = languages.get(index) {
|
||||
let code = code.clone();
|
||||
self.switch_language(&code, cx);
|
||||
}
|
||||
return;
|
||||
}
|
||||
let Some(entry) = crate::actions::entry_for_menu_id(item) else {
|
||||
println!("[menu] unknown item {item}");
|
||||
return;
|
||||
@@ -910,8 +918,6 @@ impl<E: AppEngine> OakApp<E> {
|
||||
println!("[view] full screen: {} (placeholder)", self.full_screen);
|
||||
self.rebuild_menu_bar(cx);
|
||||
}
|
||||
A::LangZh => self.switch_language(crate::i18n::Language::ZhCN, cx),
|
||||
A::LangEn => self.switch_language(crate::i18n::Language::EnUs, cx),
|
||||
A::Preferences => self.open_preferences(cx),
|
||||
// --- Playback (the program monitor) ----------------------------
|
||||
A::PlayPause => {
|
||||
@@ -1338,8 +1344,8 @@ impl<E: AppEngine> OakApp<E> {
|
||||
/// Switches the UI language live: updates the [`i18n`] global, rebuilds
|
||||
/// the menu bar (so the menu labels and the language checkmark move
|
||||
/// immediately), and repaints the whole shell.
|
||||
fn switch_language(&mut self, language: crate::i18n::Language, cx: &mut Context<Self>) {
|
||||
crate::i18n::set_language(language);
|
||||
fn switch_language(&mut self, code: &str, cx: &mut Context<Self>) {
|
||||
crate::i18n::set_language_code(code);
|
||||
self.rebuild_menu_bar(cx);
|
||||
cx.notify();
|
||||
}
|
||||
@@ -2362,12 +2368,6 @@ fn make_menus(state: MenuState) -> Vec<MenuBarEntry> {
|
||||
menu_item(A::ThemeLight).with_checked(!state.dark),
|
||||
]);
|
||||
|
||||
let language = crate::i18n::language();
|
||||
let language_submenu = Menu::new(vec![
|
||||
menu_item(A::LangZh).with_checked(language == crate::i18n::Language::ZhCN),
|
||||
menu_item(A::LangEn).with_checked(language == crate::i18n::Language::EnUs),
|
||||
]);
|
||||
|
||||
// 工具: the mutually exclusive tool group in the registry's order, the
|
||||
// add tool growing its addable-items submenu, then snapping + proxy.
|
||||
let mut tools: Vec<MenuItem> = Vec::new();
|
||||
@@ -2478,7 +2478,7 @@ fn make_menus(state: MenuState) -> Vec<MenuBarEntry> {
|
||||
tr("menu.view"),
|
||||
Menu::new(vec![
|
||||
menu_item(A::ThemeDark).with_submenu(theme_submenu),
|
||||
menu_item(A::LangZh).with_submenu(language_submenu),
|
||||
crate::menus::shared::language_menu(),
|
||||
menu_item(A::ZoomIn).separated(),
|
||||
menu_item(A::ZoomOut),
|
||||
menu_item(A::IncreaseTrackHeight),
|
||||
@@ -2751,7 +2751,7 @@ mod tests {
|
||||
fn language_menu_tracks_the_active_language() {
|
||||
let _guard = crate::actions::shortcuts_test_lock().lock().unwrap();
|
||||
|
||||
let _guard = crate::i18n::lang_test_lock().lock().unwrap();
|
||||
let _guard = crate::i18n::lang_test_lock().lock().unwrap_or_else(|e| e.into_inner());
|
||||
|
||||
let view_entry = |dark: bool| -> MenuBarEntry {
|
||||
make_menus(MenuState::new(dark))
|
||||
@@ -2766,46 +2766,54 @@ mod tests {
|
||||
.menu
|
||||
.items
|
||||
.iter()
|
||||
.find(|item| item.id == menu_ids::LANG_ZH)
|
||||
.find(|item| item.label == crate::i18n::tr("menu.view.language"))
|
||||
.map(|item| item.submenu.clone().map(|m| *m).unwrap_or_default())
|
||||
.expect("语言/Language submenu exists")
|
||||
};
|
||||
|
||||
crate::i18n::set_language(crate::i18n::Language::EnUs);
|
||||
crate::i18n::set_language_code("en-US");
|
||||
let languages = crate::i18n::available_languages();
|
||||
let en_index = languages.iter().position(|c| c == "en-US").unwrap();
|
||||
let zh_index = languages.iter().position(|c| c == "zh-CN").unwrap();
|
||||
let submenu = language_item(&view_entry(true));
|
||||
assert_eq!(
|
||||
submenu.items.len(),
|
||||
languages.len(),
|
||||
"one menu item per discovered language pack"
|
||||
);
|
||||
let zh = submenu
|
||||
.items
|
||||
.iter()
|
||||
.find(|i| i.id == menu_ids::LANG_ZH)
|
||||
.find(|i| i.id == crate::menus::shared::LANG_ITEM_BASE + zh_index)
|
||||
.expect("zh item");
|
||||
let en = submenu
|
||||
.items
|
||||
.iter()
|
||||
.find(|i| i.id == menu_ids::LANG_EN)
|
||||
.find(|i| i.id == crate::menus::shared::LANG_ITEM_BASE + en_index)
|
||||
.expect("en item");
|
||||
assert_eq!(zh.label, "简体中文");
|
||||
assert_eq!(en.label, "English");
|
||||
assert_eq!(zh.label, "简体中文 (zh-CN)");
|
||||
assert_eq!(en.label, "English (en-US)");
|
||||
assert_eq!(zh.checked, Some(false));
|
||||
assert_eq!(en.checked, Some(true), "en-US is active → checked");
|
||||
|
||||
crate::i18n::set_language(crate::i18n::Language::ZhCN);
|
||||
crate::i18n::set_language_code("zh-CN");
|
||||
let submenu = language_item(&view_entry(true));
|
||||
let zh = submenu
|
||||
.items
|
||||
.iter()
|
||||
.find(|i| i.id == menu_ids::LANG_ZH)
|
||||
.find(|i| i.id == crate::menus::shared::LANG_ITEM_BASE + zh_index)
|
||||
.expect("zh item");
|
||||
let en = submenu
|
||||
.items
|
||||
.iter()
|
||||
.find(|i| i.id == menu_ids::LANG_EN)
|
||||
.find(|i| i.id == crate::menus::shared::LANG_ITEM_BASE + en_index)
|
||||
.expect("en item");
|
||||
assert_eq!(zh.checked, Some(true), "zh-CN is active → checked");
|
||||
assert_eq!(en.checked, Some(false));
|
||||
|
||||
// The menu titles themselves are localized.
|
||||
assert_eq!(view_entry(true).title, "视图(V)");
|
||||
crate::i18n::set_language(crate::i18n::Language::EnUs);
|
||||
crate::i18n::set_language_code("en-US");
|
||||
assert_eq!(view_entry(true).title, "View(V)");
|
||||
}
|
||||
|
||||
@@ -2814,7 +2822,7 @@ mod tests {
|
||||
fn theme_menu_checkmark_follows_dark_flag() {
|
||||
let _guard = crate::actions::shortcuts_test_lock().lock().unwrap();
|
||||
|
||||
let _guard = crate::i18n::lang_test_lock().lock().unwrap();
|
||||
let _guard = crate::i18n::lang_test_lock().lock().unwrap_or_else(|e| e.into_inner());
|
||||
|
||||
let dark_item = |dark: bool| -> gpui_widgets::menu::MenuItem {
|
||||
let entries = make_menus(MenuState::new(dark));
|
||||
@@ -2845,8 +2853,8 @@ mod tests {
|
||||
#[test]
|
||||
fn window_menu_lists_all_panels_and_checks_the_open_ones() {
|
||||
let _guard = crate::actions::shortcuts_test_lock().lock().unwrap();
|
||||
let _guard = crate::i18n::lang_test_lock().lock().unwrap();
|
||||
crate::i18n::set_language(crate::i18n::Language::EnUs);
|
||||
let _guard = crate::i18n::lang_test_lock().lock().unwrap_or_else(|e| e.into_inner());
|
||||
crate::i18n::set_language_code("en-US");
|
||||
|
||||
let mask = panel_bit(INSPECTOR) | panel_bit(TIMELINE);
|
||||
let mut state = MenuState::new(true);
|
||||
@@ -2878,7 +2886,7 @@ mod tests {
|
||||
#[gpui::test]
|
||||
async fn window_menu_toggle_closes_and_reopens_the_inspector(cx: &mut TestAppContext) {
|
||||
let _guard = crate::actions::shortcuts_test_lock().lock().unwrap();
|
||||
let _guard = crate::i18n::lang_test_lock().lock().unwrap();
|
||||
let _guard = crate::i18n::lang_test_lock().lock().unwrap_or_else(|e| e.into_inner());
|
||||
let (_window, root) = mock_shell(cx);
|
||||
|
||||
// The checkmark shown in the 窗口 menu for the inspector, derived from
|
||||
@@ -2932,7 +2940,7 @@ mod tests {
|
||||
cx: &mut TestAppContext,
|
||||
) {
|
||||
let _guard = crate::actions::shortcuts_test_lock().lock().unwrap();
|
||||
let _guard = crate::i18n::lang_test_lock().lock().unwrap();
|
||||
let _guard = crate::i18n::lang_test_lock().lock().unwrap_or_else(|e| e.into_inner());
|
||||
let (window, root) = mock_shell(cx);
|
||||
|
||||
let menu_bar_before = cx.read(|app| root.read(app).menu_bar.entity_id());
|
||||
@@ -2965,7 +2973,7 @@ mod tests {
|
||||
fn file_and_edit_menus_cover_the_project_lifecycle() {
|
||||
let _guard = crate::actions::shortcuts_test_lock().lock().unwrap();
|
||||
|
||||
let _guard = crate::i18n::lang_test_lock().lock().unwrap();
|
||||
let _guard = crate::i18n::lang_test_lock().lock().unwrap_or_else(|e| e.into_inner());
|
||||
|
||||
let entry = |title: &str| -> MenuBarEntry {
|
||||
make_menus_for_test()
|
||||
@@ -2974,7 +2982,7 @@ mod tests {
|
||||
.expect("menu exists")
|
||||
};
|
||||
|
||||
crate::i18n::set_language(crate::i18n::Language::EnUs);
|
||||
crate::i18n::set_language_code("en-US");
|
||||
let file = entry("File(F)");
|
||||
for id in [
|
||||
menu_ids::NEW_PROJECT,
|
||||
@@ -3005,13 +3013,15 @@ mod tests {
|
||||
}
|
||||
|
||||
// The same ids exist in the zh-CN menu bar.
|
||||
crate::i18n::set_language(crate::i18n::Language::ZhCN);
|
||||
crate::i18n::set_language_code("zh-CN");
|
||||
let file = entry("文件(F)");
|
||||
assert!(file
|
||||
.menu
|
||||
.items
|
||||
.iter()
|
||||
.any(|item| item.id == menu_ids::EXPORT_PROJECT));
|
||||
// Restore the default language for the next (lock-free) test.
|
||||
crate::i18n::set_language_code("en-US");
|
||||
}
|
||||
|
||||
/// Opening 视图 → Preferences… must not crash: the dialog content and the
|
||||
@@ -3021,8 +3031,8 @@ mod tests {
|
||||
async fn preferences_dialog_opens_without_crashing(cx: &mut TestAppContext) {
|
||||
let _guard = crate::actions::shortcuts_test_lock().lock().unwrap();
|
||||
|
||||
let _guard = crate::i18n::lang_test_lock().lock().unwrap();
|
||||
crate::i18n::set_language(crate::i18n::Language::EnUs);
|
||||
let _guard = crate::i18n::lang_test_lock().lock().unwrap_or_else(|e| e.into_inner());
|
||||
crate::i18n::set_language_code("en-US");
|
||||
|
||||
cx.update(|cx| cx.init_colors());
|
||||
let window = cx.open_window(size(px(1600.0), px(900.0)), |window, cx| {
|
||||
@@ -3058,8 +3068,8 @@ mod tests {
|
||||
fn every_shortcut_maps_to_a_menu_item() {
|
||||
let _guard = crate::actions::shortcuts_test_lock().lock().unwrap();
|
||||
|
||||
let _guard = crate::i18n::lang_test_lock().lock().unwrap();
|
||||
crate::i18n::set_language(crate::i18n::Language::EnUs);
|
||||
let _guard = crate::i18n::lang_test_lock().lock().unwrap_or_else(|e| e.into_inner());
|
||||
crate::i18n::set_language_code("en-US");
|
||||
|
||||
fn collect(menu: &Menu, out: &mut Vec<usize>) {
|
||||
for item in &menu.items {
|
||||
@@ -3104,8 +3114,8 @@ mod tests {
|
||||
fn menu_shortcut_labels_match_the_table() {
|
||||
let _guard = crate::actions::shortcuts_test_lock().lock().unwrap();
|
||||
|
||||
let _guard = crate::i18n::lang_test_lock().lock().unwrap();
|
||||
crate::i18n::set_language(crate::i18n::Language::EnUs);
|
||||
let _guard = crate::i18n::lang_test_lock().lock().unwrap_or_else(|e| e.into_inner());
|
||||
crate::i18n::set_language_code("en-US");
|
||||
|
||||
fn walk(menu: &Menu) -> Vec<(usize, Option<gpui::SharedString>)> {
|
||||
let mut out = Vec::new();
|
||||
@@ -3140,7 +3150,7 @@ mod tests {
|
||||
async fn space_toggles_program_playback(cx: &mut TestAppContext) {
|
||||
let _guard = crate::actions::shortcuts_test_lock().lock().unwrap();
|
||||
|
||||
let _guard = crate::i18n::lang_test_lock().lock().unwrap();
|
||||
let _guard = crate::i18n::lang_test_lock().lock().unwrap_or_else(|e| e.into_inner());
|
||||
let (window, root) = mock_shell(cx);
|
||||
|
||||
let playing = cx.read(|app| root.read(app).program_clock.read(app).is_playing());
|
||||
@@ -3172,7 +3182,7 @@ mod tests {
|
||||
async fn keymap_defaults_dispatch_their_actions(cx: &mut TestAppContext) {
|
||||
let _guard = crate::actions::shortcuts_test_lock().lock().unwrap();
|
||||
|
||||
let _guard = crate::i18n::lang_test_lock().lock().unwrap();
|
||||
let _guard = crate::i18n::lang_test_lock().lock().unwrap_or_else(|e| e.into_inner());
|
||||
let (window, root) = mock_shell(cx);
|
||||
|
||||
let key = |keystroke: &str| gpui::Keystroke::parse(keystroke).unwrap();
|
||||
@@ -3285,7 +3295,7 @@ mod tests {
|
||||
async fn edit_shortcuts_dispatch_to_the_engine(cx: &mut TestAppContext) {
|
||||
let _guard = crate::actions::shortcuts_test_lock().lock().unwrap();
|
||||
|
||||
let _guard = crate::i18n::lang_test_lock().lock().unwrap();
|
||||
let _guard = crate::i18n::lang_test_lock().lock().unwrap_or_else(|e| e.into_inner());
|
||||
let (window, root) = mock_shell(cx);
|
||||
|
||||
// Move the playhead inside the first clip (the → shortcut steps one
|
||||
@@ -3341,7 +3351,7 @@ mod tests {
|
||||
async fn shortcuts_are_suppressed_while_a_modal_is_open(cx: &mut TestAppContext) {
|
||||
let _guard = crate::actions::shortcuts_test_lock().lock().unwrap();
|
||||
|
||||
let _guard = crate::i18n::lang_test_lock().lock().unwrap();
|
||||
let _guard = crate::i18n::lang_test_lock().lock().unwrap_or_else(|e| e.into_inner());
|
||||
let (window, root) = mock_shell(cx);
|
||||
|
||||
cx.update(|app| root.update(app, |app, cx| app.on_menu(menu_ids::PREFERENCES, cx)));
|
||||
@@ -3404,7 +3414,7 @@ mod tests {
|
||||
#[gpui::test]
|
||||
async fn action_search_opens_from_the_slash_key(cx: &mut TestAppContext) {
|
||||
let _guard = crate::actions::shortcuts_test_lock().lock().unwrap();
|
||||
let _lang = crate::i18n::lang_test_lock().lock().unwrap();
|
||||
let _lang = crate::i18n::lang_test_lock().lock().unwrap_or_else(|e| e.into_inner());
|
||||
let (window, root) = mock_shell(cx);
|
||||
// A leftover real shortcuts file must not shadow the default `/`
|
||||
// binding under test.
|
||||
@@ -3443,7 +3453,7 @@ mod tests {
|
||||
cx: &mut TestAppContext,
|
||||
) {
|
||||
let _guard = crate::actions::shortcuts_test_lock().lock().unwrap();
|
||||
let _lang = crate::i18n::lang_test_lock().lock().unwrap();
|
||||
let _lang = crate::i18n::lang_test_lock().lock().unwrap_or_else(|e| e.into_inner());
|
||||
let (window, root) = mock_shell(cx);
|
||||
crate::actions::reset_all_custom_shortcuts();
|
||||
cx.update(|app| root.update(app, |app, cx| app.rebind_keys(cx)));
|
||||
@@ -3481,7 +3491,7 @@ mod tests {
|
||||
cx: &mut TestAppContext,
|
||||
) {
|
||||
let _guard = crate::actions::shortcuts_test_lock().lock().unwrap();
|
||||
let _lang = crate::i18n::lang_test_lock().lock().unwrap();
|
||||
let _lang = crate::i18n::lang_test_lock().lock().unwrap_or_else(|e| e.into_inner());
|
||||
let (window, root) = mock_shell(cx);
|
||||
crate::actions::reset_all_custom_shortcuts();
|
||||
cx.update(|app| root.update(app, |app, cx| app.rebind_keys(cx)));
|
||||
@@ -3515,7 +3525,7 @@ mod tests {
|
||||
#[gpui::test]
|
||||
async fn keyboard_tab_capture_assigns_a_shortcut(cx: &mut TestAppContext) {
|
||||
let _guard = crate::actions::shortcuts_test_lock().lock().unwrap();
|
||||
let _lang = crate::i18n::lang_test_lock().lock().unwrap();
|
||||
let _lang = crate::i18n::lang_test_lock().lock().unwrap_or_else(|e| e.into_inner());
|
||||
let (window, root) = mock_shell(cx);
|
||||
crate::actions::reset_all_custom_shortcuts();
|
||||
cx.update(|app| root.update(app, |app, cx| app.on_menu(menu_ids::PREFERENCES, cx)));
|
||||
@@ -3569,7 +3579,7 @@ mod tests {
|
||||
#[gpui::test]
|
||||
async fn keyboard_tab_capture_escape_cancels_without_closing(cx: &mut TestAppContext) {
|
||||
let _guard = crate::actions::shortcuts_test_lock().lock().unwrap();
|
||||
let _lang = crate::i18n::lang_test_lock().lock().unwrap();
|
||||
let _lang = crate::i18n::lang_test_lock().lock().unwrap_or_else(|e| e.into_inner());
|
||||
let (window, root) = mock_shell(cx);
|
||||
crate::actions::reset_all_custom_shortcuts();
|
||||
cx.update(|app| root.update(app, |app, cx| app.on_menu(menu_ids::PREFERENCES, cx)));
|
||||
@@ -3611,7 +3621,7 @@ mod tests {
|
||||
#[gpui::test]
|
||||
async fn custom_shortcut_overrides_are_live_after_rebind(cx: &mut TestAppContext) {
|
||||
let _guard = crate::actions::shortcuts_test_lock().lock().unwrap();
|
||||
let _lang = crate::i18n::lang_test_lock().lock().unwrap();
|
||||
let _lang = crate::i18n::lang_test_lock().lock().unwrap_or_else(|e| e.into_inner());
|
||||
let (window, root) = mock_shell(cx);
|
||||
crate::actions::reset_all_custom_shortcuts();
|
||||
|
||||
@@ -3645,8 +3655,8 @@ mod tests {
|
||||
/// mock engine records it, so the async round trip is observable.
|
||||
#[gpui::test]
|
||||
async fn import_footage_prompts_and_routes_the_picked_path(cx: &mut TestAppContext) {
|
||||
let _guard = crate::i18n::lang_test_lock().lock().unwrap();
|
||||
crate::i18n::set_language(crate::i18n::Language::EnUs);
|
||||
let _guard = crate::i18n::lang_test_lock().lock().unwrap_or_else(|e| e.into_inner());
|
||||
crate::i18n::set_language_code("en-US");
|
||||
|
||||
cx.update(|cx| cx.init_colors());
|
||||
let window = cx.open_window(size(px(1600.0), px(900.0)), |window, cx| {
|
||||
@@ -3694,7 +3704,7 @@ mod tests {
|
||||
/// delivers it — the OS drag entering the window, then the release.
|
||||
#[gpui::test]
|
||||
async fn dropping_files_onto_the_project_browser_imports_them(cx: &mut TestAppContext) {
|
||||
let _guard = crate::i18n::lang_test_lock().lock().unwrap();
|
||||
let _guard = crate::i18n::lang_test_lock().lock().unwrap_or_else(|e| e.into_inner());
|
||||
let (window, root) = mock_shell(cx);
|
||||
let mut cx = VisualTestContext::from_window(window.into(), cx);
|
||||
|
||||
@@ -3743,7 +3753,7 @@ mod tests {
|
||||
/// correct parameters.
|
||||
#[gpui::test]
|
||||
async fn dragging_footage_onto_the_timeline_places_a_clip(cx: &mut TestAppContext) {
|
||||
let _guard = crate::i18n::lang_test_lock().lock().unwrap();
|
||||
let _guard = crate::i18n::lang_test_lock().lock().unwrap_or_else(|e| e.into_inner());
|
||||
let (window, root) = mock_shell(cx);
|
||||
let mut cx = VisualTestContext::from_window(window.into(), cx);
|
||||
|
||||
@@ -3822,7 +3832,7 @@ mod tests {
|
||||
/// grid the same entry (plus folder children) appears as icons.
|
||||
#[gpui::test]
|
||||
async fn explorer_views_list_root_level_footage(cx: &mut TestAppContext) {
|
||||
let _guard = crate::i18n::lang_test_lock().lock().unwrap();
|
||||
let _guard = crate::i18n::lang_test_lock().lock().unwrap_or_else(|e| e.into_inner());
|
||||
let (window, _root) = mock_shell(cx);
|
||||
let mut cx = VisualTestContext::from_window(window.into(), cx);
|
||||
|
||||
@@ -3905,7 +3915,7 @@ mod tests {
|
||||
gpui::WindowHandle<OakApp<MockEngine>>,
|
||||
Entity<OakApp<MockEngine>>,
|
||||
) {
|
||||
crate::i18n::set_language(crate::i18n::Language::EnUs);
|
||||
crate::i18n::set_language_code("en-US");
|
||||
cx.update(|cx| cx.init_colors());
|
||||
let window = cx.open_window(size(px(1600.0), px(900.0)), |window, cx| {
|
||||
OakApp::<MockEngine>::new(window, None, cx)
|
||||
@@ -3941,7 +3951,7 @@ mod tests {
|
||||
/// / 打开 route) drives the engine's library open and closes the dialog.
|
||||
#[gpui::test]
|
||||
async fn manager_lists_and_opens(cx: &mut TestAppContext) {
|
||||
let _guard = crate::i18n::lang_test_lock().lock().unwrap();
|
||||
let _guard = crate::i18n::lang_test_lock().lock().unwrap_or_else(|e| e.into_inner());
|
||||
let (_window, root) = mock_shell(cx);
|
||||
let content = open_manager(cx, &root);
|
||||
let rows = manager_rows(cx, &content);
|
||||
@@ -3971,7 +3981,7 @@ mod tests {
|
||||
/// and its sub-dialogs.
|
||||
#[gpui::test]
|
||||
async fn manager_create_rename_duplicate_delete(cx: &mut TestAppContext) {
|
||||
let _guard = crate::i18n::lang_test_lock().lock().unwrap();
|
||||
let _guard = crate::i18n::lang_test_lock().lock().unwrap_or_else(|e| e.into_inner());
|
||||
let (_window, root) = mock_shell(cx);
|
||||
let content = open_manager(cx, &root);
|
||||
|
||||
@@ -4080,7 +4090,7 @@ mod tests {
|
||||
async fn manager_import_and_export_route_through_the_platform_dialogs(
|
||||
cx: &mut TestAppContext,
|
||||
) {
|
||||
let _guard = crate::i18n::lang_test_lock().lock().unwrap();
|
||||
let _guard = crate::i18n::lang_test_lock().lock().unwrap_or_else(|e| e.into_inner());
|
||||
let (_window, root) = mock_shell(cx);
|
||||
let content = open_manager(cx, &root);
|
||||
|
||||
|
||||
+27
-19
@@ -148,26 +148,34 @@ impl PreferencesContent {
|
||||
});
|
||||
|
||||
// --- 常规 General: language + theme --------------------------------
|
||||
let language_options = vec![
|
||||
ComboBoxOption::new(0, "English (en-US)"),
|
||||
ComboBoxOption::new(1, "简体中文 (zh-CN)"),
|
||||
];
|
||||
// Data-driven: one option per discovered language pack, labelled
|
||||
// with the pack's own `language.name` — a newly dropped-in pack
|
||||
// appears here with no code change.
|
||||
let languages = i18n::available_languages();
|
||||
let language_options: Vec<_> = languages
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(index, code)| {
|
||||
ComboBoxOption::new(
|
||||
index,
|
||||
format!("{} ({code})", i18n::pack_native_name(code)),
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
let language = cx.new(|cx| {
|
||||
ComboBox::new(2, language_options, window, cx)
|
||||
.with_placeholder(i18n::tr("preferences.language.placeholder"))
|
||||
});
|
||||
let language_selected = match crate::i18n::language() {
|
||||
crate::i18n::Language::EnUs => 0,
|
||||
crate::i18n::Language::ZhCN => 1,
|
||||
};
|
||||
cx.subscribe(&language, |_this, _combo, event: &ComboBoxEvent, cx| {
|
||||
let language_selected = languages
|
||||
.iter()
|
||||
.position(|code| *code == i18n::language_code())
|
||||
.unwrap_or(0);
|
||||
cx.subscribe(&language, move |_this, _combo, event: &ComboBoxEvent, cx| {
|
||||
if let ComboBoxEvent::Selected { value, .. } = event {
|
||||
let language = match *value {
|
||||
1 => crate::i18n::Language::ZhCN,
|
||||
_ => crate::i18n::Language::EnUs,
|
||||
};
|
||||
crate::i18n::set_language(language);
|
||||
cx.emit(PreferencesEvent::LanguageChanged);
|
||||
if let Some(code) = languages.get(*value) {
|
||||
crate::i18n::set_language_code(code);
|
||||
cx.emit(PreferencesEvent::LanguageChanged);
|
||||
}
|
||||
}
|
||||
})
|
||||
.detach();
|
||||
@@ -2308,8 +2316,8 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn search_filter_matches_label_or_path() {
|
||||
let _guard = crate::i18n::lang_test_lock().lock().unwrap();
|
||||
crate::i18n::set_language(crate::i18n::Language::EnUs);
|
||||
let _guard = crate::i18n::lang_test_lock().lock().unwrap_or_else(|e| e.into_inner());
|
||||
crate::i18n::set_language_code("en-US");
|
||||
assert!(search_filter_matches(ActionId::NewProject, "File", "new"));
|
||||
assert!(search_filter_matches(ActionId::NewProject, "File", "file > new"));
|
||||
assert!(!search_filter_matches(ActionId::NewProject, "File", "undo"));
|
||||
@@ -2329,8 +2337,8 @@ mod tests {
|
||||
#[test]
|
||||
fn keyboard_rows_cover_the_menu_bar() {
|
||||
let _guard = crate::actions::shortcuts_test_lock().lock().unwrap();
|
||||
let _lang = crate::i18n::lang_test_lock().lock().unwrap();
|
||||
crate::i18n::set_language(crate::i18n::Language::EnUs);
|
||||
let _lang = crate::i18n::lang_test_lock().lock().unwrap_or_else(|e| e.into_inner());
|
||||
crate::i18n::set_language_code("en-US");
|
||||
let rows = crate::app::menu_action_paths();
|
||||
assert!(!rows.is_empty());
|
||||
// Every listed action resolves to a registry entry with a menu item.
|
||||
|
||||
+104
-147
@@ -14,21 +14,20 @@
|
||||
// You should have received a copy of the GNU General Public License
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
//! Localization: UI strings live in YAML language packs under
|
||||
//! `assets/i18n/` (one `<lang>.yaml` per language, e.g. `en.yaml`,
|
||||
//! `zh-CN.yaml`), loaded at runtime and shipped inside the app bundle.
|
||||
//! Users can add or override languages by dropping extra `.yaml` files
|
||||
//! into the user pack directory (`~/.oak/i18n/`) — no rebuild needed.
|
||||
//! Localization: UI strings live in YAML language packs discovered at
|
||||
//! runtime (one `<lang>.yaml` per language — the file stem IS the
|
||||
//! language code, e.g. `en-US.yaml`, `zh-CN.yaml`). Nothing is compiled in: adding a language means
|
||||
//! dropping one more `.yaml` into any searched directory — no rebuild,
|
||||
//! no code change. Each pack carries its own display name in the
|
||||
//! `language.name` key (the endonym, e.g. "Français"), which the
|
||||
//! language pickers show.
|
||||
//!
|
||||
//! # Lookup order
|
||||
//!
|
||||
//! 1. The user pack directory (`~/.oak/i18n/`);
|
||||
//! 2. The bundled packs (next to the executable, or the app bundle's
|
||||
//! `Resources/i18n/`);
|
||||
//! 3. The dev checkout's `assets/i18n/` (running from the repo);
|
||||
//! 4. The compiled-in English/Chinese tables (the same YAML embedded
|
||||
//! with `include_str!`) — the last-resort fallback so a broken
|
||||
//! install still renders English.
|
||||
//! 2. The bundled packs (next to the executable, the app bundle's
|
||||
//! `Resources/i18n/`, or the system install's `<exe>/../share/oak/i18n/`);
|
||||
//! 3. The dev checkout's `assets/i18n/` (running from the repo).
|
||||
//!
|
||||
//! Later sources override earlier ones per key. `tr` falls back from
|
||||
//! the active language to `en-US` and then to the key itself, so a
|
||||
@@ -36,148 +35,80 @@
|
||||
//!
|
||||
//! # The language setting
|
||||
//!
|
||||
//! The active language code is a process-global string (an atomic-free
|
||||
//! The active language code is a process-global string (behind an
|
||||
//! `RwLock` — any thread can read it). At startup [`init`] loads the
|
||||
//! persisted value from the config key `Language`; unknown codes fall
|
||||
//! back to `en-US`. [`set_language`] flips the global and persists.
|
||||
//! back to `en-US`. [`set_language_code`] flips the global and persists.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::atomic::{AtomicU8, Ordering};
|
||||
use std::sync::{OnceLock, RwLock};
|
||||
|
||||
/// The languages shipped with the app (compiled-in packs).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Language {
|
||||
/// English (United States).
|
||||
EnUs,
|
||||
/// Simplified Chinese.
|
||||
ZhCN,
|
||||
}
|
||||
|
||||
impl Language {
|
||||
/// The stable config/pack-file code, e.g. `"en-US"`.
|
||||
pub fn code(self) -> &'static str {
|
||||
match self {
|
||||
Language::EnUs => "en-US",
|
||||
Language::ZhCN => "zh-CN",
|
||||
}
|
||||
}
|
||||
|
||||
/// Parses a stored config value into a built-in language. Empty or
|
||||
/// unknown values fall back to en-US (a custom pack's code is kept
|
||||
/// verbatim by [`set_language_code`]).
|
||||
fn from_code(code: &str) -> Self {
|
||||
let code = code.trim().to_ascii_lowercase();
|
||||
if code.starts_with("zh") {
|
||||
Language::ZhCN
|
||||
} else {
|
||||
Language::EnUs
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The built-in (compiled-in) packs, parsed from the same YAML files the
|
||||
/// bundle ships: the YAML is the single source of truth, embedded so a
|
||||
/// missing/corrupt install still renders English.
|
||||
const BUILTIN_PACKS: &[(&str, &str)] = &[
|
||||
("en-US", include_str!("../assets/i18n/en.yaml")),
|
||||
("zh-CN", include_str!("../assets/i18n/zh-CN.yaml")),
|
||||
];
|
||||
/// The fallback language code: the reference pack every other pack is
|
||||
/// key-compared against, the config default, and `tr`'s last resort
|
||||
/// before the raw key.
|
||||
pub const FALLBACK_CODE: &str = "en-US";
|
||||
|
||||
/// One language's key → value table (values are leaked once at load —
|
||||
/// the tables live for the whole process).
|
||||
type Table = HashMap<String, &'static str>;
|
||||
|
||||
/// All loaded packs: language code → table.
|
||||
/// All discovered packs: language code → table.
|
||||
static TABLES: OnceLock<RwLock<HashMap<String, Table>>> = OnceLock::new();
|
||||
|
||||
/// The active language code (0/1 fast path for the two built-ins; a
|
||||
/// custom pack's code lives in [`CURRENT_CUSTOM`]).
|
||||
static CURRENT: AtomicU8 = AtomicU8::new(0);
|
||||
static CURRENT_CUSTOM: RwLock<Option<String>> = RwLock::new(None);
|
||||
/// The active language code. Empty means "not yet initialized" and reads
|
||||
/// as [`FALLBACK_CODE`].
|
||||
static CURRENT_CODE: RwLock<String> = RwLock::new(String::new());
|
||||
|
||||
/// The active language (the built-in view of the current code; custom
|
||||
/// packs report the nearest built-in).
|
||||
pub fn language() -> Language {
|
||||
if CURRENT_CUSTOM
|
||||
.read()
|
||||
.unwrap_or_else(|e| e.into_inner())
|
||||
.is_some()
|
||||
{
|
||||
return match CURRENT.load(Ordering::Relaxed) {
|
||||
1 => Language::ZhCN,
|
||||
_ => Language::EnUs,
|
||||
};
|
||||
}
|
||||
match CURRENT.load(Ordering::Relaxed) {
|
||||
1 => Language::ZhCN,
|
||||
_ => Language::EnUs,
|
||||
}
|
||||
}
|
||||
|
||||
/// The active language code (`en-US`, `zh-CN`, or a pack's code).
|
||||
/// The active language code (a discovered pack's code, or `en-US`).
|
||||
pub fn language_code() -> String {
|
||||
if let Some(custom) = CURRENT_CUSTOM
|
||||
.read()
|
||||
.unwrap_or_else(|e| e.into_inner())
|
||||
.clone()
|
||||
{
|
||||
return custom;
|
||||
let code = CURRENT_CODE.read().unwrap_or_else(|e| e.into_inner());
|
||||
if code.is_empty() {
|
||||
FALLBACK_CODE.to_string()
|
||||
} else {
|
||||
code.clone()
|
||||
}
|
||||
language().code().to_string()
|
||||
}
|
||||
|
||||
/// Switches the active language live (built-in) and persists the choice.
|
||||
pub fn set_language(language: Language) {
|
||||
CURRENT.store(
|
||||
match language {
|
||||
Language::EnUs => 0,
|
||||
Language::ZhCN => 1,
|
||||
},
|
||||
Ordering::Relaxed,
|
||||
);
|
||||
CURRENT_CUSTOM
|
||||
.write()
|
||||
.unwrap_or_else(|e| e.into_inner())
|
||||
.take();
|
||||
persist_language(language.code());
|
||||
sync_widgets();
|
||||
}
|
||||
|
||||
/// Switches the active language to any loaded pack's code (a custom
|
||||
/// language pack). Unknown codes fall back to en-US.
|
||||
/// Switches the active language to any discovered pack's code and
|
||||
/// persists the choice. Unknown codes fall back to en-US.
|
||||
pub fn set_language_code(code: &str) {
|
||||
if code == Language::EnUs.code() || code == Language::ZhCN.code() {
|
||||
set_language(Language::from_code(code));
|
||||
return;
|
||||
}
|
||||
let exists = tables()
|
||||
.read()
|
||||
.unwrap_or_else(|e| e.into_inner())
|
||||
.contains_key(code);
|
||||
if !exists {
|
||||
set_language(Language::EnUs);
|
||||
return;
|
||||
}
|
||||
*CURRENT_CUSTOM
|
||||
.write()
|
||||
.unwrap_or_else(|e| e.into_inner()) = Some(code.to_string());
|
||||
let code = if exists { code } else { FALLBACK_CODE };
|
||||
*CURRENT_CODE.write().unwrap_or_else(|e| e.into_inner()) = code.to_string();
|
||||
persist_language(code);
|
||||
sync_widgets();
|
||||
}
|
||||
|
||||
/// The language codes with a loaded pack, built-ins first.
|
||||
/// A pack's own display name — its `language.name` value (the endonym,
|
||||
/// e.g. "Français") — which the language pickers show, so a newly
|
||||
/// dropped-in pack needs no code change. Falls back to the code.
|
||||
pub fn pack_native_name(code: &str) -> String {
|
||||
tables()
|
||||
.read()
|
||||
.unwrap_or_else(|e| e.into_inner())
|
||||
.get(code)
|
||||
.and_then(|t| t.get("language.name"))
|
||||
.map(|s| s.to_string())
|
||||
.unwrap_or_else(|| code.to_string())
|
||||
}
|
||||
|
||||
/// The language codes with a discovered pack: en-US first, zh-CN
|
||||
/// second, the rest alphabetically by code.
|
||||
pub fn available_languages() -> Vec<String> {
|
||||
let tables = tables().read().unwrap_or_else(|e| e.into_inner());
|
||||
let mut codes = vec![Language::EnUs.code().to_string(), Language::ZhCN.code().to_string()];
|
||||
let mut extra: Vec<String> = tables
|
||||
.keys()
|
||||
.filter(|code| !codes.contains(code))
|
||||
.cloned()
|
||||
.collect();
|
||||
extra.sort();
|
||||
codes.extend(extra);
|
||||
codes
|
||||
let mut codes: Vec<String> = tables.keys().cloned().collect();
|
||||
codes.sort();
|
||||
let mut out = Vec::with_capacity(codes.len());
|
||||
for preferred in [FALLBACK_CODE, "zh-CN"] {
|
||||
if let Some(pos) = codes.iter().position(|c| c == preferred) {
|
||||
out.push(codes.remove(pos));
|
||||
}
|
||||
}
|
||||
out.extend(codes);
|
||||
out
|
||||
}
|
||||
|
||||
/// Loads the persisted language from the config. Called once at startup.
|
||||
@@ -204,7 +135,7 @@ pub fn tr(key: &'static str) -> &'static str {
|
||||
if let Some(value) = tables.get(¤t).and_then(|t| t.get(key)) {
|
||||
return value;
|
||||
}
|
||||
if let Some(value) = tables.get(Language::EnUs.code()).and_then(|t| t.get(key)) {
|
||||
if let Some(value) = tables.get(FALLBACK_CODE).and_then(|t| t.get(key)) {
|
||||
return value;
|
||||
}
|
||||
key
|
||||
@@ -248,9 +179,8 @@ fn tables() -> &'static RwLock<HashMap<String, Table>> {
|
||||
TABLES.get_or_init(|| RwLock::new(load_packs()))
|
||||
}
|
||||
|
||||
/// The directories searched for packs, lowest precedence first: the
|
||||
/// compiled-in tables are the base; each directory's packs override the
|
||||
/// previous source per key.
|
||||
/// The directories searched for packs, lowest precedence first; each
|
||||
/// directory's packs override the previous source per key.
|
||||
fn pack_dirs() -> Vec<std::path::PathBuf> {
|
||||
let mut dirs = Vec::new();
|
||||
// Dev checkout (running from the repo root or a subdir).
|
||||
@@ -259,9 +189,11 @@ fn pack_dirs() -> Vec<std::path::PathBuf> {
|
||||
if let Some(dir) = exe.parent() {
|
||||
// Portable layout: <exe>/i18n.
|
||||
dirs.push(dir.join("i18n"));
|
||||
// macOS bundle: <exe>/../Resources/i18n.
|
||||
if let Some(contents) = dir.parent() {
|
||||
// macOS bundle: <exe>/../Resources/i18n.
|
||||
dirs.push(contents.join("Resources/i18n"));
|
||||
// System install (deb/rpm/pkg): <exe>/../share/oak/i18n.
|
||||
dirs.push(contents.join("share/oak/i18n"));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -289,14 +221,11 @@ fn parse_pack(code: &str, source: &str) -> Table {
|
||||
table
|
||||
}
|
||||
|
||||
/// Loads the built-in packs and every pack file found in [`pack_dirs`]
|
||||
/// (later sources override earlier ones per key; a file named
|
||||
/// `<code>.yaml` registers or extends language `<code>`).
|
||||
/// Loads every pack file found in [`pack_dirs`] (later sources override
|
||||
/// earlier ones per key; a file named `<code>.yaml` registers or extends
|
||||
/// language `<code>`).
|
||||
fn load_packs() -> HashMap<String, Table> {
|
||||
let mut packs: HashMap<String, Table> = HashMap::new();
|
||||
for (code, source) in BUILTIN_PACKS {
|
||||
packs.insert(code.to_string(), parse_pack(code, source));
|
||||
}
|
||||
for dir in pack_dirs() {
|
||||
let Ok(read_dir) = std::fs::read_dir(&dir) else {
|
||||
continue;
|
||||
@@ -324,6 +253,12 @@ fn load_packs() -> HashMap<String, Table> {
|
||||
.extend(table);
|
||||
}
|
||||
}
|
||||
if packs.is_empty() {
|
||||
eprintln!(
|
||||
"[i18n] no language packs found in {:?}; UI strings degrade to raw keys",
|
||||
pack_dirs()
|
||||
);
|
||||
}
|
||||
packs
|
||||
}
|
||||
|
||||
@@ -338,18 +273,35 @@ fn reload_for_test() {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// The two compiled-in packs parse and cover the same key set.
|
||||
/// Every shipped pack (the YAML files in `assets/i18n/`) parses and
|
||||
/// covers exactly the en-US key set, and carries its `language.name`
|
||||
/// display name. User packs from `$HOME/.oak/i18n` are deliberately
|
||||
/// not held to the key set (a partial override pack is legitimate).
|
||||
#[test]
|
||||
fn every_key_exists_in_both_languages() {
|
||||
fn every_shipped_pack_matches_en_keys() {
|
||||
let tables = tables().read().unwrap_or_else(|e| e.into_inner());
|
||||
let en = tables.get("en-US").expect("en-US pack");
|
||||
let zh = tables.get("zh-CN").expect("zh-CN pack");
|
||||
assert!(!en.is_empty() && !zh.is_empty(), "packs parse to non-empty tables");
|
||||
for key in en.keys() {
|
||||
assert!(zh.contains_key(key), "key {key} missing from the zh-CN pack");
|
||||
}
|
||||
for key in zh.keys() {
|
||||
assert!(en.contains_key(key), "key {key} missing from the en-US pack");
|
||||
assert!(!en.is_empty(), "en-US pack parses to a non-empty table");
|
||||
for entry in std::fs::read_dir("assets/i18n").expect("dev checkout packs") {
|
||||
let path = entry.unwrap().path();
|
||||
if path.extension().and_then(|e| e.to_str()) != Some("yaml") {
|
||||
continue;
|
||||
}
|
||||
let code = path.file_stem().unwrap().to_str().unwrap().to_string();
|
||||
let table = tables
|
||||
.get(&code)
|
||||
.unwrap_or_else(|| panic!("{code} pack"));
|
||||
assert!(!table.is_empty(), "{code} pack parses to a non-empty table");
|
||||
assert!(
|
||||
table.contains_key("language.name"),
|
||||
"{code} pack has no language.name display name"
|
||||
);
|
||||
for key in en.keys() {
|
||||
assert!(table.contains_key(key), "key {key} missing from the {code} pack");
|
||||
}
|
||||
for key in table.keys() {
|
||||
assert!(en.contains_key(key), "key {key} missing from the en-US pack");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -391,18 +343,21 @@ mod tests {
|
||||
#[test]
|
||||
fn tr_falls_back_to_english_then_the_key() {
|
||||
let _guard = crate::i18n::test_lock();
|
||||
set_language(Language::ZhCN);
|
||||
set_language_code("zh-CN");
|
||||
assert_eq!(tr("menu.file"), "文件(F)");
|
||||
set_language(Language::EnUs);
|
||||
set_language_code("en-US");
|
||||
assert_eq!(tr("menu.file"), "File(F)");
|
||||
assert_eq!(tr("no.such.key"), "no.such.key");
|
||||
}
|
||||
|
||||
/// A custom pack on disk overrides built-in values and registers a
|
||||
/// new language.
|
||||
/// new language. The active language is pinned to en-US: the user
|
||||
/// pack overrides the built-in en-US table, so the assertions below
|
||||
/// must not inherit another test's active language.
|
||||
#[test]
|
||||
fn user_pack_overrides_and_extends() {
|
||||
let _guard = crate::i18n::test_lock();
|
||||
set_language_code("en-US");
|
||||
let dir = std::env::temp_dir().join(format!("oak_i18n_pack_{}", std::process::id()));
|
||||
let home = dir.join("home");
|
||||
let pack_dir = home.join(".oak/i18n");
|
||||
@@ -419,6 +374,8 @@ mod tests {
|
||||
unsafe { std::env::remove_var("HOME") };
|
||||
reload_for_test();
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
// Leave the process language at the default for the next test.
|
||||
set_language_code("en-US");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -43,6 +43,41 @@ pub const COLOR_LABEL_BASE: usize = LOCAL_ID_BASE;
|
||||
/// gray).
|
||||
pub const COLOR_LABEL_COUNT: usize = 16;
|
||||
|
||||
/// The first id reserved for the dynamic language items: one per
|
||||
/// discovered language pack, `LANG_ITEM_BASE + index` into
|
||||
/// [`crate::i18n::available_languages`]. Language switching is
|
||||
/// data-driven (a new pack is a new YAML file, not a new registry
|
||||
/// action), so these ids live above the registry range like the color
|
||||
/// labels.
|
||||
pub const LANG_ITEM_BASE: usize = COLOR_LABEL_BASE + COLOR_LABEL_COUNT;
|
||||
|
||||
/// The reserved language-item id range (far more than any plausible
|
||||
/// pack count).
|
||||
pub const LANG_ITEM_COUNT: usize = 32;
|
||||
|
||||
/// Maps a menu item id in the language range back to its index into
|
||||
/// [`crate::i18n::available_languages`].
|
||||
pub fn language_item_index(item: usize) -> Option<usize> {
|
||||
(item >= LANG_ITEM_BASE && item < LANG_ITEM_BASE + LANG_ITEM_COUNT)
|
||||
.then(|| item - LANG_ITEM_BASE)
|
||||
}
|
||||
|
||||
/// Builds the language submenu: one item per discovered pack, labelled
|
||||
/// with the pack's own `language.name` endonym plus its code, the active
|
||||
/// pack checkmarked.
|
||||
pub fn language_menu() -> MenuItem {
|
||||
let current = crate::i18n::language_code();
|
||||
let items = crate::i18n::available_languages()
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(index, code)| {
|
||||
let label = format!("{} ({code})", crate::i18n::pack_native_name(code));
|
||||
MenuItem::new(LANG_ITEM_BASE + index, label).with_checked(*code == current)
|
||||
})
|
||||
.collect();
|
||||
MenuItem::new(0, crate::i18n::tr("menu.view.language")).with_submenu(Menu::new(items))
|
||||
}
|
||||
|
||||
/// One menu item straight from the registry: id and label come from the
|
||||
/// entry, the shortcut annotation from
|
||||
/// [`display_shortcut`](crate::actions::display_shortcut) — the same recipe
|
||||
@@ -375,6 +410,10 @@ mod tests {
|
||||
/// defaults each radio group to its first entry.
|
||||
#[test]
|
||||
fn viewer_menu_offers_every_zoom_level() {
|
||||
// The label lookups below race with tests that flip the process
|
||||
// language: pin en-US under the shared lock.
|
||||
let _guard = crate::i18n::lang_test_lock().lock().unwrap_or_else(|e| e.into_inner());
|
||||
crate::i18n::set_language_code("en-US");
|
||||
let menu = viewer_menu(1);
|
||||
let zoom = menu
|
||||
.items
|
||||
@@ -394,6 +433,10 @@ mod tests {
|
||||
/// (default) entry only.
|
||||
#[test]
|
||||
fn viewer_menu_radio_groups_default_to_the_first_entry() {
|
||||
// The label lookups below race with tests that flip the process
|
||||
// language: pin en-US under the shared lock.
|
||||
let _guard = crate::i18n::lang_test_lock().lock().unwrap_or_else(|e| e.into_inner());
|
||||
crate::i18n::set_language_code("en-US");
|
||||
let menu = viewer_menu(1);
|
||||
for label_key in [
|
||||
"viewer.context.playback_resolution",
|
||||
@@ -417,6 +460,9 @@ mod tests {
|
||||
/// The resolution radio follows the current playback divider.
|
||||
#[test]
|
||||
fn viewer_menu_resolution_radio_follows_the_divider() {
|
||||
// Label lookup: pin en-US under the shared language lock.
|
||||
let _guard = crate::i18n::lang_test_lock().lock().unwrap_or_else(|e| e.into_inner());
|
||||
crate::i18n::set_language_code("en-US");
|
||||
let menu = viewer_menu(4);
|
||||
let item = menu
|
||||
.items
|
||||
|
||||
+8
-6
@@ -78,15 +78,17 @@ pub(crate) fn chip(colors: &gpui::colors::Colors, label: impl gpui::IntoElement)
|
||||
}
|
||||
|
||||
/// The viewer panel title, per the design: `<面板>·<素材/序列名>` in zh-CN,
|
||||
/// `<Panel> · <name>` in en-US — no redundant "Source"/"Program" placeholder
|
||||
/// suffix. The panel key is the localized panel name; `name` is the media or
|
||||
/// sequence name (data, not translated).
|
||||
/// `<Panel> · <name>` everywhere else — no redundant "Source"/"Program"
|
||||
/// placeholder suffix. The panel key is the localized panel name; `name` is
|
||||
/// the media or sequence name (data, not translated).
|
||||
pub(crate) fn viewer_title(panel_key: &'static str, name: &str) -> String {
|
||||
if name.is_empty() {
|
||||
return crate::i18n::tr(panel_key).to_owned();
|
||||
}
|
||||
match crate::i18n::language() {
|
||||
crate::i18n::Language::ZhCN => format!("{}·{}", crate::i18n::tr(panel_key), name),
|
||||
crate::i18n::Language::EnUs => format!("{} · {}", crate::i18n::tr(panel_key), name),
|
||||
// CJK locales read better without the spaced separator.
|
||||
if crate::i18n::language_code() == "zh-CN" {
|
||||
format!("{}·{}", crate::i18n::tr(panel_key), name)
|
||||
} else {
|
||||
format!("{} · {}", crate::i18n::tr(panel_key), name)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -574,6 +574,10 @@ mod tests {
|
||||
/// labels, the reveal entries and properties.
|
||||
#[test]
|
||||
fn node_menu_carries_grouping_and_reveals() {
|
||||
// The color-label lookup below matches on a localized label: pin
|
||||
// en-US under the shared language lock.
|
||||
let _guard = crate::i18n::lang_test_lock().lock().unwrap_or_else(|e| e.into_inner());
|
||||
crate::i18n::set_language_code("en-US");
|
||||
let menu = node_menu();
|
||||
let ids: Vec<usize> = menu.items.iter().map(|item| item.id).collect();
|
||||
assert!(ids.contains(&LOCAL_GROUP));
|
||||
|
||||
Reference in New Issue
Block a user