patois 0.2.3

A lightweight, domain-aware i18n library for Rust desktop applications.
Documentation
//! wxWidgets-specific helpers for apps that use `patois` for translations, gated behind
//! the `ui` feature. Generalizes the patois+wxDragon wiring an app would otherwise have to
//! reimplement itself: activating a locale (both the app's own catalog and wxWidgets' own
//! `wxstd` UI strings via [`crate::embed_wx_translations!`]), and populating a language
//! selector control.

use wxdragon::{translations::Translations, widgets::choice::Choice};

use crate::{LanguageInfo, LanguageManager};

/// Owns runtime language state for a patois domain in a wxDragon app: which languages are
/// available, which one is active, and keeping `wxdragon::translations::Translations` (both
/// the app's own catalog and wxWidgets' own `wxstd` strings) in sync with it.
///
/// Construct once per app (typically behind a `OnceLock`/`Mutex` singleton owned by the
/// app, since that lifecycle is app state, not something this type manages itself) and call
/// [`Self::initialize`] before creating any UI.
pub struct WxTranslationManager {
	domain: String,
	current_language: String,
	available_languages: Vec<LanguageInfo>,
	initialized: bool,
}

impl WxTranslationManager {
	/// Create a manager for the given patois domain (e.g. `"paperback"`).
	#[must_use]
	pub fn new(domain: &str) -> Self {
		Self {
			domain: domain.to_string(),
			current_language: "en".to_string(),
			available_languages: vec![LanguageInfo { code: "en".to_string(), name: "English".to_string() }],
			initialized: false,
		}
	}

	/// Detects the system language (falling back to English if unavailable), activates it for
	/// both the app's own domain and wxWidgets' own strings, and installs `wxstd_loader` so
	/// `Translations::add_std_catalog` can find them. Idempotent: subsequent calls are no-ops.
	///
	/// `wxstd_loader` is normally the `WxStdCatalogLoader` type generated by
	/// [`crate::embed_wx_translations!`] in the calling crate.
	pub fn initialize(&mut self, wxstd_loader: impl wxdragon::translations::TranslationsLoader + 'static) -> bool {
		if self.initialized {
			return true;
		}
		let translations = Translations::new();
		// Must be installed before add_std_catalog so wx consults it for the language.
		translations.set_loader(wxstd_loader);
		let mgr = LanguageManager::new(&self.domain);
		self.available_languages = mgr.available();
		let raw_sys_lang = LanguageManager::system_language();
		self.current_language =
			best_available_language(&raw_sys_lang, &self.available_languages).unwrap_or_else(|| "en".to_string());
		if self.current_language != "en" {
			translations.set_language_str(&self.current_language);
		}
		translations.add_std_catalog();
		Translations::set_global(translations);
		crate::set_default_domain(&self.domain);
		crate::set_locale(&self.current_language);
		self.initialized = true;
		true
	}

	/// Switches the active language, replacing the global `Translations` (a fresh instance
	/// needs its own loader installed before `add_std_catalog` picks the language catalog).
	/// Returns `false` if not yet initialized or `language_code` isn't available.
	pub fn set_language(
		&mut self,
		language_code: &str,
		wxstd_loader: impl wxdragon::translations::TranslationsLoader + 'static,
	) -> bool {
		if !self.initialized || !self.is_language_available(language_code) {
			return false;
		}
		self.current_language = language_code.to_string();
		let translations = Translations::new();
		translations.set_loader(wxstd_loader);
		translations.set_language_str(language_code);
		translations.add_std_catalog();
		Translations::set_global(translations);
		crate::set_locale(language_code);
		true
	}

	#[must_use]
	pub fn current_language(&self) -> String {
		self.current_language.clone()
	}

	#[must_use]
	pub fn available_languages(&self) -> Vec<LanguageInfo> {
		self.available_languages.clone()
	}

	#[must_use]
	pub fn is_language_available(&self, language_code: &str) -> bool {
		self.available_languages.iter().any(|lang| lang.code == language_code)
	}
}

/// The catalog that best fits `system_code`, or `None` when nothing does.
///
/// A system language carries a region the catalogs mostly do not (`de_DE` against `de`), and
/// sometimes the catalog is the one carrying it (`zh_CN`, `pt_br`). Matching only on the part
/// before the underscore therefore left a reader whose system says `zh_CN` with an English
/// interface, in the language with one of the most complete translations, until they found the
/// setting by hand. The order below is the same one Android's resource matching uses:
///
/// 1. the code exactly as the system gives it,
/// 2. the same code ignoring case, since a catalog may be `pt_br` where the system says `pt_BR`,
/// 3. the bare language, for `de_DE` against `de`,
/// 4. any catalog for that language, so a reader in Taiwan gets the Simplified Chinese one
///    rather than English.
#[must_use]
pub fn best_available_language(system_code: &str, available: &[LanguageInfo]) -> Option<String> {
	let language_of = |code: &str| code.split(['_', '-']).next().unwrap_or(code).to_lowercase();
	let wanted = system_code.to_lowercase();
	let language = language_of(system_code);
	let find = |matches: &dyn Fn(&str) -> bool| available.iter().find(|l| matches(&l.code)).map(|l| l.code.clone());
	find(&|code| code == system_code)
		.or_else(|| find(&|code| code.to_lowercase() == wanted))
		.or_else(|| find(&|code| code.to_lowercase() == language))
		.or_else(|| find(&|code| language_of(code) == language))
}

/// Populates `combo` with each language's display name and returns the parallel list of
/// codes (same index as the combo's items), for later lookup via [`resolve_language_choice`].
pub fn populate_language_choice(combo: &Choice, languages: &[LanguageInfo]) -> Vec<String> {
	let mut codes = Vec::with_capacity(languages.len());
	for lang in languages {
		combo.append(&lang.name);
		codes.push(lang.code.clone());
	}
	codes
}

/// Resolves `combo`'s current selection back to a language code via the parallel `codes`
/// list returned by [`populate_language_choice`]. `None` if nothing is selected or the
/// selection index is out of range.
#[must_use]
pub fn resolve_language_choice(combo: &Choice, codes: &[String]) -> Option<String> {
	combo.get_selection().and_then(|index| usize::try_from(index).ok()).and_then(|index| codes.get(index).cloned())
}

#[cfg(test)]
mod tests {
	use std::borrow::Cow;

	use wxdragon::translations::TranslationsLoader;

	use super::*;

	/// A no-op loader for exercising `WxTranslationManager`'s state machine without needing
	/// a real macro-generated `WxStdCatalogLoader` (which only exists in a consuming app
	/// crate that actually calls `embed_wx_translations!()`).
	struct NoopLoader;

	impl TranslationsLoader for NoopLoader {
		fn load_catalog(&self, _domain: &str, _lang: &str) -> Option<Cow<'_, [u8]>> {
			None
		}

		fn available_translations(&self, _domain: &str) -> Vec<String> {
			Vec::new()
		}
	}

	#[test]
	fn new_manager_has_english_available_by_default() {
		let manager = WxTranslationManager::new("test_domain");
		assert_eq!(manager.current_language(), "en");
		assert!(manager.is_language_available("en"));
		assert!(!manager.is_language_available("zz"));
	}

	#[test]
	fn set_language_fails_when_not_initialized() {
		let mut manager = WxTranslationManager::new("test_domain");
		assert!(!manager.set_language("en", NoopLoader));
		assert_eq!(manager.current_language(), "en");
	}

	#[test]
	fn available_languages_returns_clone() {
		let manager = WxTranslationManager::new("test_domain");
		let mut langs = manager.available_languages();
		langs.push(LanguageInfo { code: "xx".to_string(), name: "Fake".to_string() });
		assert!(!manager.is_language_available("xx"));
	}

	fn available(codes: &[&str]) -> Vec<LanguageInfo> {
		codes.iter().map(|c| LanguageInfo { code: (*c).to_string(), name: (*c).to_string() }).collect()
	}

	#[test]
	fn an_exact_code_wins() {
		let langs = available(&["en", "de", "zh_CN"]);
		assert_eq!(best_available_language("zh_CN", &langs).as_deref(), Some("zh_CN"));
	}

	/// The system says `pt_BR`, the catalog is named `pt_br`.
	#[test]
	fn the_same_code_in_another_case_still_matches() {
		let langs = available(&["en", "pt_br"]);
		assert_eq!(best_available_language("pt_BR", &langs).as_deref(), Some("pt_br"));
	}

	#[test]
	fn a_region_the_catalogs_do_not_have_falls_back_to_the_language() {
		let langs = available(&["en", "de"]);
		assert_eq!(best_available_language("de_DE", &langs).as_deref(), Some("de"));
	}

	/// The reason this exists: the system carries the region and so does the catalog, but they
	/// are not the same region.
	#[test]
	fn another_region_of_the_same_language_is_better_than_english() {
		let langs = available(&["en", "zh_CN"]);
		assert_eq!(best_available_language("zh_TW", &langs).as_deref(), Some("zh_CN"));
	}

	#[test]
	fn a_language_with_no_catalog_matches_nothing() {
		let langs = available(&["en", "de"]);
		assert_eq!(best_available_language("ja_JP", &langs), None);
	}
}