diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs index ed7ad602e..9a7f3ebd4 100644 --- a/crates/ui-egui/src/i18n.rs +++ b/crates/ui-egui/src/i18n.rs @@ -132,6 +132,39 @@ language_table! { Ka, "ka", "ქართული", "Geor", include_str!("../locales/ka.json"); } +impl Locale { + /// Every language in the order the Settings row and Edit ▸ Language list them: English first + /// (the source language), then the rest by their own name with accents folded, so Čeština + /// sits under C. Names in other scripts follow the Latin ones in code point order: Cyrillic, + /// Georgian, then the CJK languages. + pub fn for_picker() -> Vec { + let mut rest: Vec = Self::ALL.iter().copied().filter(|l| *l != Self::En).collect(); + rest.sort_by_cached_key(|l| name_sort_key(l.name())); + std::iter::once(Self::En).chain(rest).collect() + } +} + +/// `name` lowercased with the Latin accents that occur in language names folded away, so a +/// byte-wise sort reads alphabetically. Other scripts are left as they are. +fn name_sort_key(name: &str) -> String { + name.chars() + .flat_map(char::to_lowercase) + .map(|c| match c { + 'à'..='å' | 'ā' | 'ă' | 'ą' => 'a', + 'ç' | 'ć' | 'č' => 'c', + 'è'..='ë' | 'ē' | 'ě' | 'ę' | 'ế' | 'ệ' => 'e', + 'ì'..='ï' | 'ī' => 'i', + 'ñ' | 'ń' | 'ň' => 'n', + 'ò'..='ö' | 'ø' | 'ő' => 'o', + 'ś' | 'š' => 's', + 'ù'..='ü' | 'ū' | 'ů' | 'ű' => 'u', + 'ý' | 'ÿ' => 'y', + 'ź' | 'ż' | 'ž' => 'z', + c => c, + }) + .collect() +} + // The settings file stores the BCP-47 code (`"zh-hans"`), never the Rust variant name, so a // language can be renamed in code without invalidating anyone's settings. impl Serialize for Locale { @@ -1601,3 +1634,25 @@ mod tests { set_language(Locale::En); } } + +#[cfg(test)] +mod picker_order_tests { + use super::*; + + #[test] + fn picker_lists_english_first_then_names_alphabetically() { + let order = Locale::for_picker(); + assert_eq!(order.first(), Some(&Locale::En)); + assert_eq!(order.len(), Locale::ALL.len()); + let keys: Vec = order[1..].iter().map(|l| name_sort_key(l.name())).collect(); + assert!(keys.windows(2).all(|w| w[0] <= w[1]), "{keys:?}"); + assert_eq!(name_sort_key("Čeština"), "cestina"); + } + + #[test] + fn language_menu_lists_the_picker_order() { + let menu: Vec<&str> = crate::menus::LANGUAGE_COMMANDS.iter().map(|c| c.1).collect(); + let picker: Vec<&str> = Locale::for_picker().into_iter().map(Locale::name).collect(); + assert_eq!(menu, picker, "Edit ▸ Language and the Settings row list the languages the same way"); + } +} diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 743bd4600..704231d6b 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -15,19 +15,21 @@ pub type UiCommand = (&'static str, &'static str, Option<&'static str>, &'static /// has a matching menu command (also listed through the control channel). Tests check coverage. pub const LANGUAGE_COMMANDS: &[UiCommand] = &[ ("app.language.english", crate::i18n::Locale::En.name(), None, "Edit>Language"), - ("app.language.simplifiedChinese", crate::i18n::Locale::ZhHans.name(), None, "Edit>Language"), - ("app.language.traditionalChinese", crate::i18n::Locale::ZhHant.name(), None, "Edit>Language"), - ("app.language.japanese", crate::i18n::Locale::Ja.name(), None, "Edit>Language"), - ("app.language.portuguese", crate::i18n::Locale::PtBr.name(), None, "Edit>Language"), - ("app.language.french", crate::i18n::Locale::Fr.name(), None, "Edit>Language"), - ("app.language.spanish", crate::i18n::Locale::Es.name(), None, "Edit>Language"), + // The picker order (`Locale::for_picker`): alphabetical by the language's own name after + // English; Cyrillic, Georgian, then CJK names follow the Latin ones. A test keeps them equal. + ("app.language.czech", crate::i18n::Locale::Cs.name(), None, "Edit>Language"), ("app.language.german", crate::i18n::Locale::De.name(), None, "Edit>Language"), + ("app.language.spanish", crate::i18n::Locale::Es.name(), None, "Edit>Language"), + ("app.language.french", crate::i18n::Locale::Fr.name(), None, "Edit>Language"), + ("app.language.italian", crate::i18n::Locale::It.name(), None, "Edit>Language"), + ("app.language.polish", crate::i18n::Locale::Pl.name(), None, "Edit>Language"), + ("app.language.portuguese", crate::i18n::Locale::PtBr.name(), None, "Edit>Language"), ("app.language.russian", crate::i18n::Locale::Ru.name(), None, "Edit>Language"), ("app.language.ukrainian", crate::i18n::Locale::Uk.name(), None, "Edit>Language"), - ("app.language.polish", crate::i18n::Locale::Pl.name(), None, "Edit>Language"), - ("app.language.czech", crate::i18n::Locale::Cs.name(), None, "Edit>Language"), - ("app.language.italian", crate::i18n::Locale::It.name(), None, "Edit>Language"), ("app.language.georgian", crate::i18n::Locale::Ka.name(), None, "Edit>Language"), + ("app.language.japanese", crate::i18n::Locale::Ja.name(), None, "Edit>Language"), + ("app.language.simplifiedChinese", crate::i18n::Locale::ZhHans.name(), None, "Edit>Language"), + ("app.language.traditionalChinese", crate::i18n::Locale::ZhHant.name(), None, "Edit>Language"), ]; /// Every UI command: the languages, then everything else. `xtask parity` reads both tables from diff --git a/crates/ui-egui/src/panels/settings.rs b/crates/ui-egui/src/panels/settings.rs index 682e2acb2..e6dd6179e 100644 --- a/crates/ui-egui/src/panels/settings.rs +++ b/crates/ui-egui/src/panels/settings.rs @@ -106,7 +106,7 @@ pub(super) fn choices(ui: &mut egui::Ui, id: &str, options: fn general_tab(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens) { row(ui, t, crate::i18n::tr("Language"), |ui| { - let languages: Vec<_> = crate::i18n::Locale::ALL.iter().map(|language| (*language, language.name())).collect(); + let languages: Vec<_> = crate::i18n::Locale::for_picker().into_iter().map(|language| (language, language.name())).collect(); // Thirteen language names do not fit one line in a narrow window: wrap them. ui.horizontal_wrapped(|ui| { choices(ui, "settingsLanguage", &languages, &mut app.ui.language);