From 70d2e70f447382ee04abc33a451089bb5158a8bc Mon Sep 17 00:00:00 2001 From: devmobasa <4170275+devmobasa@users.noreply.github.com> Date: Mon, 19 Oct 2026 11:39:54 +0200 Subject: [PATCH] feat(configurator): make the language row easy to recognize Put the language picker first in General UI, with a globe and a title that includes every supported language. Derive its title and search terms from shared language metadata. Add regression coverage for the title, icon and row order, and format the additions with the pinned Rust toolchain. --- configurator/src/app/component/tests.rs | 48 +++++++++++++++++++++--- configurator/src/app/pages/mod.rs | 40 +++++++++++++++++++- configurator/src/app/pages/ui/general.rs | 19 +++++----- configurator/src/app/search/summary.rs | 6 ++- configurator/src/app/search/terms.rs | 3 -- configurator/src/models/fields/tests.rs | 23 ++++++++++++ configurator/src/models/fields/theme.rs | 19 ++++++++++ docs/I18N.md | 30 ++++++++------- locales/zh-CN.toml | 1 - src/domain/language.rs | 22 +++++++++++ 10 files changed, 175 insertions(+), 36 deletions(-) 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("语言") + ); + } }