diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs index f3ef8ee0d..47908f33c 100644 --- a/crates/ui-egui/src/i18n.rs +++ b/crates/ui-egui/src/i18n.rs @@ -664,7 +664,7 @@ 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 { +pub fn name_sort_key(name: &str) -> String { name.chars() .flat_map(char::to_lowercase) .map(|c| match c { diff --git a/crates/ui-egui/src/menus/tests.rs b/crates/ui-egui/src/menus/tests.rs index ed1fd2180..fdf970a6f 100644 --- a/crates/ui-egui/src/menus/tests.rs +++ b/crates/ui-egui/src/menus/tests.rs @@ -768,3 +768,67 @@ fn new_window_is_disabled_until_it_opens_a_window() { let e = app.run("window.newWindow", json!({})).unwrap_err(); assert!(!e.contains("M11.5"), "a user-facing message, not a milestone: {e}"); } + +/// #4143: Window menu panels and panel submenus are listed in alphabetical order according to the +/// active interface language (e.g. French), rather than staying frozen in English alphabetical order. +#[test] +fn window_menu_panels_are_sorted_alphabetically_in_active_language() { + use crate::i18n::{Lang, current, name_sort_key, set_current, tr, tr_id}; + + let prev = current(); + + // 1. In English: panels between separator 2 and 3 are alphabetical. + set_current(Lang::EN); + let tree = menu_tree(); + let (_, window) = tree.iter().find(|(t, _)| *t == "Window").unwrap(); + + let seps: Vec = window.iter().enumerate().filter(|(_, it)| matches!(it, Item::Sep)).map(|(i, _)| i).collect(); + assert!(seps.len() >= 3, "Window menu should have at least 3 separators"); + let panels = &window[seps[1] + 1..seps[2]]; + + let item_display = |it: &Item, lang: Lang| -> String { + match it { + Item::Cmd(l, id, _) => tr_id(lang, id, l).to_string(), + Item::Sub(l, _) | Item::Todo(l, _) | Item::Header(l) => tr(lang, l).to_string(), + Item::Sep => String::new(), + } + }; + + let en_keys: Vec = panels.iter().map(|it| name_sort_key(&item_display(it, Lang::EN))).collect(); + assert!(en_keys.windows(2).all(|w| w[0] <= w[1]), "English panels must be alphabetical: {en_keys:?}"); + + // 2. In French: panels are sorted alphabetically by their French names. + let fr = Lang::from_code("fr").unwrap(); + set_current(fr); + let tree_fr = menu_tree(); + let (_, window_fr) = tree_fr.iter().find(|(t, _)| *t == "Window").unwrap(); + let seps_fr: Vec = window_fr.iter().enumerate().filter(|(_, it)| matches!(it, Item::Sep)).map(|(i, _)| i).collect(); + let panels_fr = &window_fr[seps_fr[1] + 1..seps_fr[2]]; + + let fr_labels: Vec = panels_fr.iter().map(|it| item_display(it, fr)).collect(); + let fr_keys: Vec = fr_labels.iter().map(|l| name_sort_key(l)).collect(); + assert!(fr_keys.windows(2).all(|w| w[0] <= w[1]), "French panels must be alphabetical: {fr_labels:?}"); + + // Specific French placements: + // Calques (Layers) is under C, Plans de travail (Artboards) is under P, Nuancier (Swatches) is under N. + let pos = |name: &str| fr_labels.iter().position(|l| l == name).unwrap_or_else(|| panic!("missing {name} in {fr_labels:?}")); + assert!(pos("Aspect") < pos("Calques"), "Aspect before Calques"); + assert!(pos("Calques") < pos("Dégradé"), "Calques before Dégradé"); + assert!(pos("Nuancier") < pos("Plans de travail"), "Nuancier before Plans de travail"); + assert!(pos("Plans de travail") < pos("Texte"), "Plans de travail before Texte"); + + // 3. Type submenu is also alphabetical in French. + let type_sub = panels_fr + .iter() + .find_map(|it| match it { + Item::Sub(l, items) if *l == "Type" => Some(items), + _ => None, + }) + .unwrap(); + let type_labels: Vec = type_sub.iter().map(|it| item_display(it, fr)).collect(); + let type_keys: Vec = type_labels.iter().map(|l| name_sort_key(l)).collect(); + assert!(type_keys.windows(2).all(|w| w[0] <= w[1]), "Type submenu must be alphabetical: {type_labels:?}"); + assert_eq!(type_labels, vec!["Caractère", "Glyphes", "OpenType", "Paragraphe", "Styles de caractère", "Styles de paragraphe", "Tabulations",]); + + set_current(prev); +} diff --git a/crates/ui-egui/src/menus/tree.rs b/crates/ui-egui/src/menus/tree.rs index 2d3a81725..3bdb929e7 100644 --- a/crates/ui-egui/src/menus/tree.rs +++ b/crates/ui-egui/src/menus/tree.rs @@ -597,9 +597,8 @@ pub fn menu_tree_named(english_names: bool) -> Vec<(&'static str, Vec)> { v.extend(SAVED_VIEW_IDS.iter().map(|id| c("Saved View", id))); v }), - ( - "Window", - vec![ + ("Window", { + let mut window_items = vec![ c("New Window", "window.newWindow"), sub("Arrange", vec![todo("Cascade"), todo("Tile"), todo("Float in Window"), todo("Consolidate All Windows")]), sub("Workspace", crate::workspaces::menu_items()), @@ -609,6 +608,9 @@ pub fn menu_tree_named(english_names: bool) -> Vec<(&'static str, Vec)> { c("Tools", "window.toolbar"), sub("Toolbars", vec![c("Advanced", "window.toolbarAdvanced"), c("Double Column", "window.toolbarColumns")]), Sep, + ]; + let lang = crate::i18n::current(); + let mut panels = vec![ panel("Actions", "actions"), panel("Align", "align"), panel("Appearance", "appearance"), @@ -643,9 +645,8 @@ pub fn menu_tree_named(english_names: bool) -> Vec<(&'static str, Vec)> { panel("Symbols", "symbols"), panel("Transform", "transform"), panel("Transparency", "transparency"), - sub( - "Type", - vec![ + sub("Type", { + let mut type_items = vec![ panel("Character", "character"), panel("Character Styles", "charStyles"), panel("Glyphs", "glyphs"), @@ -653,16 +654,25 @@ pub fn menu_tree_named(english_names: bool) -> Vec<(&'static str, Vec)> { panel("Paragraph", "paragraph"), panel("Paragraph Styles", "paraStyles"), panel("Tabs", "tabs"), - ], - ), + ]; + sort_by_localized_label(&mut type_items, lang); + type_items + }), panel("Variables", crate::panels::variables::ID), - Sep, + ]; + sort_by_localized_label(&mut panels, lang); + window_items.extend(panels); + window_items.push(Sep); + let mut lib_items = vec![ sub("Brush Libraries", crate::panels::brushes::window_menu()), sub("Graphic Style Libraries", crate::panels::graphic_styles::window_menu()), sub("Swatch Libraries", crate::panels::swatches::window_menu()), sub("Symbol Libraries", crate::panels::symbols::window_menu()), - ], - ), + ]; + sort_by_localized_label(&mut lib_items, lang); + window_items.extend(lib_items); + window_items + }), ( "Help", vec![ @@ -709,3 +719,22 @@ pub(super) fn arrange_items() -> Vec { c("Send to Current Layer", "object.arrange.sendToCurrentLayer"), ] } + +/// Sort menu items alphabetically by their display label in `lang`, folding Latin accents so +/// panels and submenus appear in the expected alphabetical order in every interface language (#4143). +fn sort_by_localized_label(items: &mut [Item], lang: crate::i18n::Lang) { + items.sort_by_cached_key(|it| { + let label = match it { + Item::Cmd(l, id, _) => crate::i18n::tr_id(lang, id, l), + Item::Sub(l, _) | Item::Todo(l, _) | Item::Header(l) => crate::i18n::tr(lang, l), + Item::Sep => "", + }; + ( + crate::i18n::name_sort_key(label), + match it { + Item::Cmd(l, ..) | Item::Sub(l, ..) | Item::Todo(l, ..) | Item::Header(l) => *l, + Item::Sep => "", + }, + ) + }); +}