mirror of
https://github.com/devmobasa/wayscriber.git
synced 2026-11-01 07:36:42 +01:00
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:
1 parent
0d855869ff
commit
70d2e70f44
10 files changed
+175
-36
No files matched your search
@@ -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<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", || {
|
||||
@@ -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));
|
||||
@@ -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(),
|
||||
@@ -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> {
|
||||
|
||||
@@ -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(>k::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,
|
||||
{
|
||||
@@ -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.
|
||||
|
||||
@@ -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."),
|
||||
@@ -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(),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
@@ -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,
|
||||
|
||||
+16
-14
@@ -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/<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.
|
||||
|
||||
@@ -982,7 +982,6 @@
|
||||
"Label text color" = "标签文字颜色"
|
||||
"Label vertical padding" = "标签垂直内边距"
|
||||
"Landscape" = "横向"
|
||||
"Language" = "语言"
|
||||
"Laser" = "激光"
|
||||
"Laser Pointer" = "激光指示"
|
||||
"Laser pointer" = "激光指示"
|
||||
|
||||
@@ -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("语言")
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user