Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 42 additions & 6 deletions configurator/src/app/component/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};
Expand Down Expand Up @@ -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<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>) {
app.emit(Message::TabSelected(TabId::Daemon));
pump_until("the background service status", || {
Expand Down Expand Up @@ -289,7 +321,7 @@ fn focus_caret_and_scroll_survive_a_language_change(app: &Controller<Configurato
"focus moved to {focused:?}"
);
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.
app.emit(Message::TabSelected(TabId::Drawing));
Expand Down Expand Up @@ -464,9 +496,13 @@ fn focus(app: &Controller<ConfiguratorApp>) -> Option<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 pending = vec![page];
let mut pending = vec![root];
while let Some(widget) = pending.pop() {
pending.extend(std::iter::successors(
widget.first_child(),
Expand All @@ -487,11 +523,11 @@ fn visible_labels(app: &Controller<ConfiguratorApp>) -> Vec<String> {
}

fn language_row(app: &Controller<ConfiguratorApp>) -> Option<adw::ComboRow> {
let title = wayscriber::i18n::tr("Language");
let title = LanguageOption::row_title();
visible_page_descendants(app)
.into_iter()
.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> {
Expand Down
40 changes: 39 additions & 1 deletion configurator/src/app/pages/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<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
O: Copy + PartialEq + 'static,
{
Expand All @@ -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) {
Expand All @@ -387,7 +425,7 @@ impl PageBuilder {
}
}
}));
self
added
}

/// Adds a fully custom row or widget to the current group.
Expand Down
19 changes: 10 additions & 9 deletions configurator/src/app/pages/ui/general.rs
Original file line number Diff line number Diff line change
Expand Up @@ -15,10 +15,19 @@ use super::options;
pub(super) fn build(sender: &ComponentSender<ConfiguratorApp>) -> 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."),
Expand All @@ -35,14 +44,6 @@ pub(super) fn build(sender: &ComponentSender<ConfiguratorApp>) -> 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(),
Expand Down
6 changes: 4 additions & 2 deletions configurator/src/app/search/summary.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
3 changes: 0 additions & 3 deletions configurator/src/app/search/terms.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
23 changes: 23 additions & 0 deletions configurator/src/models/fields/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
19 changes: 19 additions & 0 deletions configurator/src/models/fields/theme.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Self> {
vec![
LanguageOption::Auto,
Expand All @@ -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 {
match self {
LanguageOption::Auto => Language::Auto,
Expand Down
30 changes: 16 additions & 14 deletions docs/I18N.md
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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/<code>.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.
Expand Down
1 change: 0 additions & 1 deletion locales/zh-CN.toml
Original file line number Diff line number Diff line change
Expand Up @@ -982,7 +982,6 @@
"Label text color" = "标签文字颜色"
"Label vertical padding" = "标签垂直内边距"
"Landscape" = "横向"
"Language" = "语言"
"Laser" = "激光"
"Laser Pointer" = "激光指示"
"Laser pointer" = "激光指示"
Expand Down
22 changes: 22 additions & 0 deletions src/domain/language.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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("语言")
);
}
}
Loading