From 48263b339d384de26dae8b59d6ccc6153d698c6e Mon Sep 17 00:00:00 2001 From: Nikolai Nyegaard Date: Wed, 21 Oct 2026 12:01:35 +0200 Subject: [PATCH] Preferences: list the languages alphabetically by their own name MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - English (and Auto where offered) stays first as the source language and fallback; the rest sort by native name with accents folded, so Čeština is under C and Magyar under M; Cyrillic and CJK names follow the Latin ones - The list used the registry order, which is the order the languages were added --- crates/ui-egui/src/i18n/mod.rs | 47 ++++++++++++++++++++++++++ crates/ui-egui/src/native_menu.rs | 2 +- crates/ui-egui/src/prefs_dialog.rs | 4 +-- crates/ui-egui/src/tests_nativemenu.rs | 2 +- 4 files changed, 51 insertions(+), 4 deletions(-) diff --git a/crates/ui-egui/src/i18n/mod.rs b/crates/ui-egui/src/i18n/mod.rs index 138db5c1f..3a29b4173 100644 --- a/crates/ui-egui/src/i18n/mod.rs +++ b/crates/ui-egui/src/i18n/mod.rs @@ -312,6 +312,16 @@ impl Lang { LANGUAGES.iter().map(Lang) } + /// Every registered language in the order a picker lists them: English first (the source + /// language and the fallback), then the rest by their own name with accents folded, so + /// Čeština sits under C rather than after Z. Names in other scripts follow the Latin ones in + /// code point order: Greek, Cyrillic, Arabic, then the CJK languages. + pub fn for_picker() -> Vec { + let mut rest: Vec = Lang::all().filter(|l| l.code() != Lang::EN.code()).collect(); + rest.sort_by_cached_key(|l| name_sort_key(l.name())); + std::iter::once(Lang::EN).chain(rest).collect() + } + pub fn name(self) -> &'static str { self.0.name } @@ -596,3 +606,40 @@ pub fn tn(n: u64, one: &str, other: &str) -> String { #[cfg(test)] mod tests; + +/// `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() +} + +#[cfg(test)] +mod picker_order_tests { + use super::*; + + #[test] + fn picker_lists_english_first_then_names_alphabetically() { + let order = Lang::for_picker(); + assert_eq!(order.first().map(|l| l.code()), Some(Lang::EN.code())); + assert_eq!(order.len(), Lang::all().count()); + 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"); + assert_eq!(name_sort_key("Türkçe"), "turkce"); + } +} diff --git a/crates/ui-egui/src/native_menu.rs b/crates/ui-egui/src/native_menu.rs index 83427dd58..ad4c02e86 100644 --- a/crates/ui-egui/src/native_menu.rs +++ b/crates/ui-egui/src/native_menu.rs @@ -365,7 +365,7 @@ pub fn mac_layout(app: &VectorcraftApp, bar: &MenuBar, lang: Lang) -> Layout { /// with the `interfaceLanguage` preference checked. fn language_node(app: &VectorcraftApp, lang: Lang) -> Node { let mut children = vec![Node::Item(native_item(app, "Automatic", "app.language", json!({"lang": "auto"}))), Node::Separator]; - children.extend(Lang::all().map(|l| Node::Item(native_item(app, l.name(), "app.language", json!({"lang": l.code()}))))); + children.extend(Lang::for_picker().into_iter().map(|l| Node::Item(native_item(app, l.name(), "app.language", json!({"lang": l.code()}))))); Node::Submenu { label: tr(lang, "Language").into(), children } } diff --git a/crates/ui-egui/src/prefs_dialog.rs b/crates/ui-egui/src/prefs_dialog.rs index 41f99f81a..da73fc832 100644 --- a/crates/ui-egui/src/prefs_dialog.rs +++ b/crates/ui-egui/src/prefs_dialog.rs @@ -348,8 +348,8 @@ fn category_fields(ui: &mut egui::Ui, d: &mut Dialog, cat: &str) { // `auto` plus every registered language, by its own name. labeled(ui, sp.label, |ui| { let cur = v.as_str().unwrap_or("auto"); - let codes: Vec<&str> = std::iter::once("auto").chain(crate::i18n::Lang::all().map(|l| l.code())).collect(); - let labels: Vec<&str> = std::iter::once(tl!("Auto")).chain(crate::i18n::Lang::all().map(|l| l.name())).collect(); + let codes: Vec<&str> = std::iter::once("auto").chain(crate::i18n::Lang::for_picker().into_iter().map(|l| l.code())).collect(); + let labels: Vec<&str> = std::iter::once(tl!("Auto")).chain(crate::i18n::Lang::for_picker().into_iter().map(|l| l.name())).collect(); let cur_label = codes.iter().position(|c| c.eq_ignore_ascii_case(cur)).and_then(|i| labels.get(i).copied()).unwrap_or(cur); if let Some(i) = widgets::dropdown(ui, sp.key, cur_label, &labels, 260.0) && let Some(code) = codes.get(i) diff --git a/crates/ui-egui/src/tests_nativemenu.rs b/crates/ui-egui/src/tests_nativemenu.rs index 8eadec221..3d6f19b6e 100644 --- a/crates/ui-egui/src/tests_nativemenu.rs +++ b/crates/ui-egui/src/tests_nativemenu.rs @@ -135,7 +135,7 @@ fn language_and_appearance_are_in_the_app_menu() { assert_eq!(ids(submenu(app_menu, "Language"))[..2], ["app.language", "---"], "Automatic, then the languages"); let langs: Vec<(String, Option)> = items(submenu(app_menu, "Language")).iter().map(|it| (it.label.clone(), it.checked)).collect(); let mut want = vec![("Automatic".to_string(), Some(false))]; - want.extend(Lang::all().map(|l| (l.name().to_string(), Some(l.code() == "ja")))); + want.extend(Lang::for_picker().into_iter().map(|l| (l.name().to_string(), Some(l.code() == "ja")))); assert_eq!(langs, want); // In every UI language the languages keep their own names. for ui in Lang::all() {