diff --git a/configurator/src/app/component/tests.rs b/configurator/src/app/component/tests.rs index 7682eeed6..082f47e30 100644 --- a/configurator/src/app/component/tests.rs +++ b/configurator/src/app/component/tests.rs @@ -12,7 +12,9 @@ //! selection, and the visible page keeps its scroll; //! - navigation works in the new language (finding N1); //! - a load that changes the language keeps the resets it always makes; -//! - repeated switches leave no extra pages or bindings behind. +//! - repeated switches leave no extra pages or bindings behind; +//! - the Language row comes first on the UI tab, with its icon and a title in +//! every supported language, whichever language is active. use std::path::Path; use std::time::{Duration, Instant}; @@ -120,9 +122,39 @@ fn run_scenario() { repeated_switches_leave_nothing_behind(&app, pages, bindings); a_load_that_changes_the_language_keeps_its_resets(&app); service_buttons_describe_service_actions_in_each_language(&app); + the_language_row_leads_the_ui_tab_in_each_language(&app); super::rebuild::assert_widget_paths_find_only_the_same_shape(); } +fn the_language_row_leads_the_ui_tab_in_each_language(app: &Controller) { + app.emit(Message::TabSelected(TabId::Ui)); + for (language, title) in [ + (LanguageOption::SimplifiedChinese, "语言 / Language"), + (LanguageOption::English, "Language / 语言"), + ] { + app.emit(Message::UiLanguageChanged(language)); + settle(); + let row = language_row(app).unwrap_or_else(|| panic!("no {title:?} row")); + assert_eq!(row.title(), title); + // The first row of General UI, which tops the UI tab. + let rows = row.parent().expect("the group's row list"); + assert_eq!( + rows.first_child(), + Some(row.clone().upcast::()), + "{title:?}" + ); + let icons: Vec<_> = descendants(row.upcast()) + .into_iter() + .filter_map(|widget| widget.downcast::().ok()) + .filter_map(|image| image.icon_name().map(|icon| icon.to_string())) + .collect(); + assert!( + icons.iter().any(|icon| icon == LanguageOption::ROW_ICON), + "{title:?} has no globe: {icons:?}" + ); + } +} + fn service_buttons_describe_service_actions_in_each_language(app: &Controller) { app.emit(Message::TabSelected(TabId::Daemon)); pump_until("the background service status", || { @@ -289,7 +321,7 @@ fn focus_caret_and_scroll_survive_a_language_change(app: &Controller) -> Option { } fn visible_page_descendants(app: &Controller) -> Vec { - let page = app.widgets().stack.visible_child().expect("visible page"); + descendants(app.widgets().stack.visible_child().expect("visible page")) +} + +/// `root` and every widget under it, hidden ones included. +fn descendants(root: gtk::Widget) -> Vec { let mut found = Vec::new(); - let mut pending = vec![page]; + let mut pending = vec![root]; while let Some(widget) = pending.pop() { pending.extend(std::iter::successors( widget.first_child(), @@ -487,11 +523,11 @@ fn visible_labels(app: &Controller) -> Vec { } fn language_row(app: &Controller) -> Option { - let title = wayscriber::i18n::tr("Language"); + let title = LanguageOption::row_title(); visible_page_descendants(app) .into_iter() .filter_map(|widget| widget.downcast::().ok()) - .find(|row| row.title() == title) + .find(|row| row.title() == title.as_str()) } fn scrollable(app: &Controller) -> Option { diff --git a/configurator/src/app/pages/mod.rs b/configurator/src/app/pages/mod.rs index cff67a55b..e90806696 100644 --- a/configurator/src/app/pages/mod.rs +++ b/configurator/src/app/pages/mod.rs @@ -353,6 +353,43 @@ impl PageBuilder { get: impl Fn(&ConfiguratorApp) -> O + 'static, to_message: impl Fn(O) -> Message + 'static, ) -> &mut Self + where + O: Copy + PartialEq + 'static, + { + self.add_combo_row(title, subtitle, values, labels, get, to_message); + self + } + + /// A [`combo_row`](Self::combo_row) with a symbolic icon before its + /// title, for a row that has to be found by sight: the icon reads the same + /// in every interface language. `choices` pairs the values with their + /// labels, as `options` builds them. + pub(crate) fn icon_combo_row( + &mut self, + icon_name: &str, + title: &str, + subtitle: &str, + (values, labels): (Vec, Vec), + get: impl Fn(&ConfiguratorApp) -> O + 'static, + to_message: impl Fn(O) -> Message + 'static, + ) -> &mut Self + where + O: Copy + PartialEq + 'static, + { + let row = self.add_combo_row(title, subtitle, values, labels, get, to_message); + row.add_prefix(>k::Image::from_icon_name(icon_name)); + self + } + + fn add_combo_row( + &mut self, + title: &str, + subtitle: &str, + values: Vec, + labels: Vec, + get: impl Fn(&ConfiguratorApp) -> O + 'static, + to_message: impl Fn(O) -> Message + 'static, + ) -> adw::ComboRow where O: Copy + PartialEq + 'static, { @@ -374,6 +411,7 @@ impl PageBuilder { }) }; self.group.add(&row); + let added = row.clone(); self.bindings.push(Box::new(move |app, _summary| { let current = get(app); if let Some(index) = values.iter().position(|value| *value == current) { @@ -387,7 +425,7 @@ impl PageBuilder { } } })); - self + added } /// Adds a fully custom row or widget to the current group. diff --git a/configurator/src/app/pages/ui/general.rs b/configurator/src/app/pages/ui/general.rs index 3f44665ff..8e5f72031 100644 --- a/configurator/src/app/pages/ui/general.rs +++ b/configurator/src/app/pages/ui/general.rs @@ -15,10 +15,19 @@ use super::options; pub(super) fn build(sender: &ComponentSender) -> BuiltPage { let (themes, theme_labels) = options(UiThemeOption::list(), |value| value.label()); let (motions, motion_labels) = options(ReducedMotionOption::list(), |value| value.label()); - let (languages, language_labels) = options(LanguageOption::list(), |value| value.label()); let mut page = PageBuilder::new(sender, TabId::Ui); page.group_in_area(tr("General UI"), SearchArea::UiGeneral) + // First, with an icon and a title in every language: the way back + // from an interface language the user cannot read. + .icon_combo_row( + LanguageOption::ROW_ICON, + &LanguageOption::row_title(), + tr("Previews here at once; Save keeps it. The overlay and the About window use it the next time they open, background mode after it restarts. \"Automatic\" follows your desktop locale."), + options(LanguageOption::list(), |value| value.label()), + |app| app.draft.ui_language, + Message::UiLanguageChanged, + ) .combo_row( tr("Theme"), tr("\"Auto\" uses dark. \"Light\" supports the command palette, including shortcut editing and tooltips; other popups still use dark."), @@ -35,14 +44,6 @@ pub(super) fn build(sender: &ComponentSender) -> BuiltPage { |app| app.draft.ui_reduced_motion, Message::UiReducedMotionChanged, ) - .combo_row( - tr("Language"), - tr("Previews here at once; Save keeps it. The overlay and the About window use it the next time they open, background mode after it restarts. \"Automatic\" follows your desktop locale."), - languages, - language_labels, - |app| app.draft.ui_language, - Message::UiLanguageChanged, - ) .entry_row( tr("Preferred output (GNOME fallback)"), |app| app.draft.ui_preferred_output.clone(), diff --git a/configurator/src/app/search/summary.rs b/configurator/src/app/search/summary.rs index 851247cc1..197c66d52 100644 --- a/configurator/src/app/search/summary.rs +++ b/configurator/src/app/search/summary.rs @@ -181,15 +181,17 @@ fn performance_matches(query: &SearchQuery, summary: &mut TabSearchSummary) { } fn ui_matches(query: &SearchQuery, summary: &mut TabSearchSummary) { + // Each language's own name and its word for "language" find the Language + // row even when the interface uses a different script. let general_terms: Vec<_> = UI_GENERAL_TERMS .iter() .copied() .chain( wayscriber::domain::Language::ALL .into_iter() - .filter_map(wayscriber::domain::Language::native_name), + .flat_map(|language| [language.native_name(), language.word_for_language()]) + .flatten(), ) - .chain(LANGUAGE_SETTING_TERMS.iter().copied()) .collect(); add_area_if( query, diff --git a/configurator/src/app/search/terms.rs b/configurator/src/app/search/terms.rs index 056a860a0..79f224d48 100644 --- a/configurator/src/app/search/terms.rs +++ b/configurator/src/app/search/terms.rs @@ -186,9 +186,6 @@ pub(super) const HISTORY_CUSTOM_TERMS: &[&str] = &[ "steps", "delay", ]; -/// Words for "language" stay searchable even when the current UI uses a -/// different script. Add each supported language's word here. -pub(super) const LANGUAGE_SETTING_TERMS: &[&str] = &["语言"]; pub(super) const UI_GENERAL_TERMS: &[&str] = &[ "general ui", diff --git a/configurator/src/models/fields/tests.rs b/configurator/src/models/fields/tests.rs index 00bce7497..7167414d7 100644 --- a/configurator/src/models/fields/tests.rs +++ b/configurator/src/models/fields/tests.rs @@ -206,6 +206,29 @@ fn language_option_labels_name_each_language_in_itself() { ); } +#[test] +fn language_row_title_names_the_setting_in_every_language() { + crate::test_env::in_language_child( + concat!( + module_path!(), + "::language_row_title_names_the_setting_in_every_language" + ), + || { + use wayscriber::domain::Language; + + // The interface language reads first, and the others follow, so + // the row is recognizable from any of them. + for (language, title) in [ + (Language::English, "Language / 语言"), + (Language::SimplifiedChinese, "语言 / Language"), + ] { + wayscriber::i18n::set_language(language); + assert_eq!(LanguageOption::row_title(), title); + } + }, + ); +} + #[test] fn session_storage_and_compression_round_trip() { let storage = SessionStorageModeOption::from_mode(SessionStorageMode::Custom); diff --git a/configurator/src/models/fields/theme.rs b/configurator/src/models/fields/theme.rs index 0ed7ce5ae..fded39e84 100644 --- a/configurator/src/models/fields/theme.rs +++ b/configurator/src/models/fields/theme.rs @@ -104,6 +104,9 @@ pub enum LanguageOption { } impl LanguageOption { + /// A globe, which marks the Language row in every interface language. + pub const ROW_ICON: &'static str = "preferences-desktop-locale-symbolic"; + pub fn list() -> Vec { vec![ LanguageOption::Auto, @@ -123,6 +126,22 @@ impl LanguageOption { } } + /// The Language row's title: the word for "language" in the interface + /// language, then in each other supported language (`语言 / Language`), + /// so a user who cannot read the interface still recognizes the row. + pub fn row_title() -> String { + let active = wayscriber::i18n::language(); + std::iter::once(active) + .chain( + Language::ALL + .into_iter() + .filter(|language| *language != active), + ) + .filter_map(Language::word_for_language) + .collect::>() + .join(" / ") + } + pub fn to_language(self) -> Language { match self { LanguageOption::Auto => Language::Auto, diff --git a/docs/I18N.md b/docs/I18N.md index 04b2989e6..c87cc5bfe 100644 --- a/docs/I18N.md +++ b/docs/I18N.md @@ -19,10 +19,13 @@ language = "auto" today — leaves the slot empty and the search continues; English is the final fallback, so `auto` never fails. -The configurator's Language row lists each language by its own name -("English", "简体中文"), so a language you cannot read can always be switched -back. Picking one previews it in the configurator window at once; nothing is -written until Save. Each other process reads the saved setting when it starts: +The configurator's Language row is the first row of the UI tab and has a globe +icon. Its title names the setting in every supported language, the interface +language first: `Language / 语言` in English, `语言 / Language` in Chinese. +The row lists each language by its own name ("English", "简体中文"), so a +language you cannot read can always be switched back. Picking one previews it +in the configurator window at once; nothing is written until Save. Each other +process reads the saved setting when it starts: - the overlay, the next time it opens; - the About window, the next time it opens; @@ -187,22 +190,21 @@ section order. ## Adding a language 1. `src/domain/language.rs`: add a variant, its `code()`, its - `catalog_code()`, its serde name and aliases, its entry in `ALL`, and its - `native_name()` and `english_name()`. `--list-languages` reads all of - these; the configurator's Language row takes its labels from - `native_name()`. + `catalog_code()`, its serde name and aliases, its entry in `ALL`, its + `native_name()` and `english_name()`, and its `word_for_language()`. + `--list-languages` reads all of these but the last. The configurator's + Language row takes its option labels from `native_name()` and its title + from `word_for_language()`, and its search matches both. 2. `src/i18n.rs`: the spellings `language_from_code` and `language_from_locale` accept, the variant's `encode`/`decode` atom, and the new file in `EMBEDDED_CATALOGS`. 3. `src/config/types/toolbar/items/definitions.rs`: a table for the language in `toolbar_item_definitions_in`, which keeps one per language. 4. `configurator/src/models/fields/theme.rs`: a `LanguageOption` for the - variant. Add its word for "language" to `LANGUAGE_SETTING_TERMS` in - `configurator/src/app/search/terms.rs` and to the aliases of - `OpenConfiguratorLanguage` in `src/config/action_meta/entries/ui.rs`. Also - add its native name to those action aliases; the configurator picks native - names up from `Language::ALL`, but the palette's aliases are static. This - lets users find the setting from another interface language. + variant. Add its native name and its word for "language" to the aliases of + `OpenConfiguratorLanguage` in `src/config/action_meta/entries/ui.rs`. The + configurator reads both from `Language::ALL`, but the palette's aliases are + static. This lets users find the setting from another interface language. 5. `locales/.toml`: translate `locales/zh-CN.toml`, keeping the keys. 6. `cargo test --test repository_guards translations` must pass, which proves the new catalog covers every marked string. diff --git a/locales/zh-CN.toml b/locales/zh-CN.toml index 3737cc595..ed5804867 100644 --- a/locales/zh-CN.toml +++ b/locales/zh-CN.toml @@ -982,7 +982,6 @@ "Label text color" = "标签文字颜色" "Label vertical padding" = "标签垂直内边距" "Landscape" = "横向" -"Language" = "语言" "Laser" = "激光" "Laser Pointer" = "激光指示" "Laser pointer" = "激光指示" diff --git a/src/domain/language.rs b/src/domain/language.rs index 511be04f9..73ecfee47 100644 --- a/src/domain/language.rs +++ b/src/domain/language.rs @@ -62,6 +62,17 @@ impl Language { } } + /// The language's word for "language", never translated, so the setting + /// that switches languages can be recognized from any of them. `None` for + /// `Auto`, which is not a language. + pub fn word_for_language(self) -> Option<&'static str> { + match self { + Self::Auto => None, + Self::English => Some("Language"), + Self::SimplifiedChinese => Some("语言"), + } + } + /// The language's English name, for command-line output and logs. pub fn english_name(self) -> &'static str { match self { @@ -109,4 +120,15 @@ mod tests { } assert_eq!(Language::SimplifiedChinese.native_name(), Some("简体中文")); } + + #[test] + fn every_language_but_auto_has_its_own_word_for_language() { + for language in Language::ALL { + assert_eq!(language.word_for_language().is_none(), language.is_auto()); + } + assert_eq!( + Language::SimplifiedChinese.word_for_language(), + Some("语言") + ); + } }