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.
This commit is contained in:
devmobasa committed 2026-10-19 11:39:54 +02:00
1 parent 0d855869ff
commit 70d2e70f44
10 files changed
+175 -36

No files matched your search

+42 -6
View File
@@ -12,7 +12,9 @@
//! selection, and the visible page keeps its scroll; //! selection, and the visible page keeps its scroll;
//! - navigation works in the new language (finding N1); //! - navigation works in the new language (finding N1);
//! - a load that changes the language keeps the resets it always makes; //! - 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::path::Path;
use std::time::{Duration, Instant}; use std::time::{Duration, Instant};
@@ -120,9 +122,39 @@ fn run_scenario() {
repeated_switches_leave_nothing_behind(&app, pages, bindings); repeated_switches_leave_nothing_behind(&app, pages, bindings);
a_load_that_changes_the_language_keeps_its_resets(&app); a_load_that_changes_the_language_keeps_its_resets(&app);
service_buttons_describe_service_actions_in_each_language(&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(); super::rebuild::assert_widget_paths_find_only_the_same_shape();
} }
fn the_language_row_leads_the_ui_tab_in_each_language(app: &Controller<ConfiguratorApp>) {
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::<gtk::Widget>()),
"{title:?}"
);
let icons: Vec<_> = descendants(row.upcast())
.into_iter()
.filter_map(|widget| widget.downcast::<gtk::Image>().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<ConfiguratorApp>) { fn service_buttons_describe_service_actions_in_each_language(app: &Controller<ConfiguratorApp>) {
app.emit(Message::TabSelected(TabId::Daemon)); app.emit(Message::TabSelected(TabId::Daemon));
pump_until("the background service status", || { pump_until("the background service status", || {
@@ -289,7 +321,7 @@ fn focus_caret_and_scroll_survive_a_language_change(app: &Controller<Configurato
"focus moved to {focused:?}" "focus moved to {focused:?}"
); );
assert_ne!(new_row, row, "the page was rebuilt"); assert_ne!(new_row, row, "the page was rebuilt");
assert_eq!(new_row.title(), "Language"); assert_eq!(new_row.title(), "Language / 语言");
// An entry keeps its caret and selection. // An entry keeps its caret and selection.
app.emit(Message::TabSelected(TabId::Drawing)); app.emit(Message::TabSelected(TabId::Drawing));
@@ -464,9 +496,13 @@ fn focus(app: &Controller<ConfiguratorApp>) -> Option<gtk::Widget> {
} }
fn visible_page_descendants(app: &Controller<ConfiguratorApp>) -> Vec<gtk::Widget> { fn visible_page_descendants(app: &Controller<ConfiguratorApp>) -> Vec<gtk::Widget> {
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<gtk::Widget> {
let mut found = Vec::new(); let mut found = Vec::new();
let mut pending = vec![page]; let mut pending = vec![root];
while let Some(widget) = pending.pop() { while let Some(widget) = pending.pop() {
pending.extend(std::iter::successors( pending.extend(std::iter::successors(
widget.first_child(), widget.first_child(),
@@ -487,11 +523,11 @@ fn visible_labels(app: &Controller<ConfiguratorApp>) -> Vec<String> {
} }
fn language_row(app: &Controller<ConfiguratorApp>) -> Option<adw::ComboRow> { fn language_row(app: &Controller<ConfiguratorApp>) -> Option<adw::ComboRow> {
let title = wayscriber::i18n::tr("Language"); let title = LanguageOption::row_title();
visible_page_descendants(app) visible_page_descendants(app)
.into_iter() .into_iter()
.filter_map(|widget| widget.downcast::<adw::ComboRow>().ok()) .filter_map(|widget| widget.downcast::<adw::ComboRow>().ok())
.find(|row| row.title() == title) .find(|row| row.title() == title.as_str())
} }
fn scrollable(app: &Controller<ConfiguratorApp>) -> Option<gtk::ScrolledWindow> { fn scrollable(app: &Controller<ConfiguratorApp>) -> Option<gtk::ScrolledWindow> {
+39 -1
View File
@@ -353,6 +353,43 @@ impl PageBuilder {
get: impl Fn(&ConfiguratorApp) -> O + 'static, get: impl Fn(&ConfiguratorApp) -> O + 'static,
to_message: impl Fn(O) -> Message + 'static, to_message: impl Fn(O) -> Message + 'static,
) -> &mut Self ) -> &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<O>(
&mut self,
icon_name: &str,
title: &str,
subtitle: &str,
(values, labels): (Vec<O>, Vec<String>),
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(&gtk::Image::from_icon_name(icon_name));
self
}
fn add_combo_row<O>(
&mut self,
title: &str,
subtitle: &str,
values: Vec<O>,
labels: Vec<String>,
get: impl Fn(&ConfiguratorApp) -> O + 'static,
to_message: impl Fn(O) -> Message + 'static,
) -> adw::ComboRow
where where
O: Copy + PartialEq + 'static, O: Copy + PartialEq + 'static,
{ {
@@ -374,6 +411,7 @@ impl PageBuilder {
}) })
}; };
self.group.add(&row); self.group.add(&row);
let added = row.clone();
self.bindings.push(Box::new(move |app, _summary| { self.bindings.push(Box::new(move |app, _summary| {
let current = get(app); let current = get(app);
if let Some(index) = values.iter().position(|value| *value == current) { 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. /// Adds a fully custom row or widget to the current group.
+10 -9
View File
@@ -15,10 +15,19 @@ use super::options;
pub(super) fn build(sender: &ComponentSender<ConfiguratorApp>) -> BuiltPage { pub(super) fn build(sender: &ComponentSender<ConfiguratorApp>) -> BuiltPage {
let (themes, theme_labels) = options(UiThemeOption::list(), |value| value.label()); let (themes, theme_labels) = options(UiThemeOption::list(), |value| value.label());
let (motions, motion_labels) = options(ReducedMotionOption::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); let mut page = PageBuilder::new(sender, TabId::Ui);
page.group_in_area(tr("General UI"), SearchArea::UiGeneral) 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( .combo_row(
tr("Theme"), tr("Theme"),
tr("\"Auto\" uses dark. \"Light\" supports the command palette, including shortcut editing and tooltips; other popups still use dark."), 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<ConfiguratorApp>) -> BuiltPage {
|app| app.draft.ui_reduced_motion, |app| app.draft.ui_reduced_motion,
Message::UiReducedMotionChanged, 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( .entry_row(
tr("Preferred output (GNOME fallback)"), tr("Preferred output (GNOME fallback)"),
|app| app.draft.ui_preferred_output.clone(), |app| app.draft.ui_preferred_output.clone(),
+4 -2
View File
@@ -181,15 +181,17 @@ fn performance_matches(query: &SearchQuery, summary: &mut TabSearchSummary) {
} }
fn ui_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 let general_terms: Vec<_> = UI_GENERAL_TERMS
.iter() .iter()
.copied() .copied()
.chain( .chain(
wayscriber::domain::Language::ALL wayscriber::domain::Language::ALL
.into_iter() .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(); .collect();
add_area_if( add_area_if(
query, query,
-3
View File
@@ -186,9 +186,6 @@ pub(super) const HISTORY_CUSTOM_TERMS: &[&str] = &[
"steps", "steps",
"delay", "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] = &[ pub(super) const UI_GENERAL_TERMS: &[&str] = &[
"general ui", "general ui",
+23
View File
@@ -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] #[test]
fn session_storage_and_compression_round_trip() { fn session_storage_and_compression_round_trip() {
let storage = SessionStorageModeOption::from_mode(SessionStorageMode::Custom); let storage = SessionStorageModeOption::from_mode(SessionStorageMode::Custom);
+19
View File
@@ -104,6 +104,9 @@ pub enum LanguageOption {
} }
impl 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<Self> { pub fn list() -> Vec<Self> {
vec![ vec![
LanguageOption::Auto, 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::<Vec<_>>()
.join(" / ")
}
pub fn to_language(self) -> Language { pub fn to_language(self) -> Language {
match self { match self {
LanguageOption::Auto => Language::Auto, LanguageOption::Auto => Language::Auto,
+16 -14
View File
@@ -19,10 +19,13 @@ language = "auto"
today — leaves the slot empty and the search continues; English is the final today — leaves the slot empty and the search continues; English is the final
fallback, so `auto` never fails. fallback, so `auto` never fails.
The configurator's Language row lists each language by its own name The configurator's Language row is the first row of the UI tab and has a globe
("English", "简体中文"), so a language you cannot read can always be switched icon. Its title names the setting in every supported language, the interface
back. Picking one previews it in the configurator window at once; nothing is language first: `Language / 语言` in English, `语言 / Language` in Chinese.
written until Save. Each other process reads the saved setting when it starts: 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 overlay, the next time it opens;
- the About window, the next time it opens; - the About window, the next time it opens;
@@ -187,22 +190,21 @@ section order.
## Adding a language ## Adding a language
1. `src/domain/language.rs`: add a variant, its `code()`, its 1. `src/domain/language.rs`: add a variant, its `code()`, its
`catalog_code()`, its serde name and aliases, its entry in `ALL`, and its `catalog_code()`, its serde name and aliases, its entry in `ALL`, its
`native_name()` and `english_name()`. `--list-languages` reads all of `native_name()` and `english_name()`, and its `word_for_language()`.
these; the configurator's Language row takes its labels from `--list-languages` reads all of these but the last. The configurator's
`native_name()`. 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 2. `src/i18n.rs`: the spellings `language_from_code` and
`language_from_locale` accept, the variant's `encode`/`decode` atom, and the `language_from_locale` accept, the variant's `encode`/`decode` atom, and the
new file in `EMBEDDED_CATALOGS`. new file in `EMBEDDED_CATALOGS`.
3. `src/config/types/toolbar/items/definitions.rs`: a table for the language in 3. `src/config/types/toolbar/items/definitions.rs`: a table for the language in
`toolbar_item_definitions_in`, which keeps one per language. `toolbar_item_definitions_in`, which keeps one per language.
4. `configurator/src/models/fields/theme.rs`: a `LanguageOption` for the 4. `configurator/src/models/fields/theme.rs`: a `LanguageOption` for the
variant. Add its word for "language" to `LANGUAGE_SETTING_TERMS` in variant. Add its native name and its word for "language" to the aliases of
`configurator/src/app/search/terms.rs` and to the aliases of `OpenConfiguratorLanguage` in `src/config/action_meta/entries/ui.rs`. The
`OpenConfiguratorLanguage` in `src/config/action_meta/entries/ui.rs`. Also configurator reads both from `Language::ALL`, but the palette's aliases are
add its native name to those action aliases; the configurator picks native static. This lets users find the setting from another interface language.
names up from `Language::ALL`, but the palette's aliases are static. This
lets users find the setting from another interface language.
5. `locales/<code>.toml`: translate `locales/zh-CN.toml`, keeping the keys. 5. `locales/<code>.toml`: translate `locales/zh-CN.toml`, keeping the keys.
6. `cargo test --test repository_guards translations` must pass, which proves 6. `cargo test --test repository_guards translations` must pass, which proves
the new catalog covers every marked string. the new catalog covers every marked string.
-1
View File
@@ -982,7 +982,6 @@
"Label text color" = "标签文字颜色" "Label text color" = "标签文字颜色"
"Label vertical padding" = "标签垂直内边距" "Label vertical padding" = "标签垂直内边距"
"Landscape" = "横向" "Landscape" = "横向"
"Language" = "语言"
"Laser" = "激光" "Laser" = "激光"
"Laser Pointer" = "激光指示" "Laser Pointer" = "激光指示"
"Laser pointer" = "激光指示" "Laser pointer" = "激光指示"
+22
View File
@@ -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. /// The language's English name, for command-line output and logs.
pub fn english_name(self) -> &'static str { pub fn english_name(self) -> &'static str {
match self { match self {
@@ -109,4 +120,15 @@ mod tests {
} }
assert_eq!(Language::SimplifiedChinese.native_name(), Some("简体中文")); 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("语言")
);
}
} }