Skip to main content

cranpose_localization/
preferences.rs

1use std::sync::{Arc, Mutex, Weak, atomic::Ordering};
2
3use portable_atomic::AtomicU64;
4
5use crate::{Catalog, DeferredMessage, Language, Locale, LocalizationError, Translator};
6
7/// A saved application language choice, independent of the current system locale.
8#[derive(Clone, Debug, Default, PartialEq, Eq)]
9pub enum LanguagePreference {
10    /// Follow the host's ordered language preferences.
11    #[default]
12    System,
13    /// Use an explicitly selected application language.
14    Selected(Language),
15}
16
17impl LanguagePreference {
18    /// Reads a saved tag. Missing or unsupported tags follow the system.
19    pub fn from_setting(value: Option<&str>, catalog: &Catalog) -> Self {
20        value
21            .and_then(|tag| {
22                catalog
23                    .languages()
24                    .iter()
25                    .find(|language| language.tag() == tag)
26            })
27            .cloned()
28            .map_or(Self::System, Self::Selected)
29    }
30
31    /// A stable value suitable for application preference storage.
32    pub fn setting(&self) -> &str {
33        match self {
34            Self::System => "system",
35            Self::Selected(language) => language.tag(),
36        }
37    }
38
39    /// Selects a formatter for this preference and the current host languages.
40    pub fn translator(&self, catalog: &Catalog, system: &[Locale]) -> Translator {
41        match self {
42            Self::System => catalog.negotiate(system),
43            Self::Selected(language) => catalog.translator(language.locale().clone()),
44        }
45    }
46}
47
48/// An application's storage adapter for its language preference.
49/// Native UI providers call these methods on a worker so storage cannot block a frame.
50pub trait LanguagePreferenceStore: Send + Sync {
51    /// Loads the saved language tag, or `None` for the system default.
52    fn load(&self) -> Result<Option<String>, LocalizationError>;
53    /// Saves a canonical language tag or `system`.
54    fn save(&self, value: &str) -> Result<(), LocalizationError>;
55}
56
57struct State {
58    preference: LanguagePreference,
59    system: Vec<Locale>,
60    choice_revision: u64,
61}
62
63struct Data {
64    catalog: Catalog,
65    store: Option<Arc<dyn LanguagePreferenceStore>>,
66    state: Mutex<State>,
67    revision: AtomicU64,
68    writes: Mutex<()>,
69}
70
71/// Shared application localization used by UI roots, workers, and native services.
72/// Each thread retains only its most recently used formatter. Language changes
73/// invalidate that formatter; repeated messages do not reconstruct Fluent bundles.
74#[derive(Clone)]
75pub struct Localization(Arc<Data>);
76
77impl PartialEq for Localization {
78    fn eq(&self, other: &Self) -> bool {
79        Arc::ptr_eq(&self.0, &other.0)
80    }
81}
82
83impl Localization {
84    /// Creates an application scope with automatic system-language selection.
85    pub fn new(catalog: Catalog, system: Vec<Locale>) -> Self {
86        Self::create(catalog, system, None)
87    }
88
89    /// Creates an application scope backed by its own preference storage.
90    pub fn persistent(
91        catalog: Catalog,
92        system: Vec<Locale>,
93        store: impl LanguagePreferenceStore + 'static,
94    ) -> Self {
95        Self::create(catalog, system, Some(Arc::new(store)))
96    }
97
98    fn create(
99        catalog: Catalog,
100        system: Vec<Locale>,
101        store: Option<Arc<dyn LanguagePreferenceStore>>,
102    ) -> Self {
103        Self(Arc::new(Data {
104            catalog,
105            store,
106            state: Mutex::new(State {
107                preference: LanguagePreference::System,
108                system,
109                choice_revision: 0,
110            }),
111            revision: AtomicU64::new(0),
112            writes: Mutex::new(()),
113        }))
114    }
115
116    /// The application's catalog and supported language choices.
117    pub fn catalog(&self) -> &Catalog {
118        &self.0.catalog
119    }
120
121    /// Whether preference changes access application storage.
122    /// Without storage, selection is immediate and needs no worker runtime.
123    pub fn is_persistent(&self) -> bool {
124        self.0.store.is_some()
125    }
126
127    /// The current saved or explicitly selected preference.
128    pub fn preference(&self) -> LanguagePreference {
129        self.0
130            .state
131            .lock()
132            .unwrap_or_else(std::sync::PoisonError::into_inner)
133            .preference
134            .clone()
135    }
136
137    /// A version that changes when the selected preference or host languages change.
138    pub fn revision(&self) -> u64 {
139        self.0.revision.load(Ordering::Acquire)
140    }
141
142    /// Loads stored preferences without replacing a newer user selection.
143    /// This can access storage; native applications should call it on a worker.
144    pub fn load(&self) -> Result<(), LocalizationError> {
145        let Some(store) = &self.0.store else {
146            return Ok(());
147        };
148        let revision = self
149            .0
150            .state
151            .lock()
152            .unwrap_or_else(std::sync::PoisonError::into_inner)
153            .choice_revision;
154        let stored = store.load()?;
155        let preference = LanguagePreference::from_setting(stored.as_deref(), self.catalog());
156        let mut state = self
157            .0
158            .state
159            .lock()
160            .unwrap_or_else(std::sync::PoisonError::into_inner);
161        if state.choice_revision == revision && state.preference != preference {
162            state.preference = preference;
163            self.0.revision.fetch_add(1, Ordering::Release);
164        }
165        Ok(())
166    }
167
168    /// Persists a choice and publishes it only after storage succeeds.
169    /// Calls are serialized; native applications should call this on a worker.
170    pub fn select(&self, preference: LanguagePreference) -> Result<(), LocalizationError> {
171        let _write = self
172            .0
173            .writes
174            .lock()
175            .unwrap_or_else(std::sync::PoisonError::into_inner);
176        if let LanguagePreference::Selected(language) = &preference
177            && !self.catalog().languages().contains(language)
178        {
179            return Err(LocalizationError::Preference(
180                "language is not offered by this catalog".into(),
181            ));
182        }
183        if let Some(store) = &self.0.store {
184            store.save(preference.setting())?;
185        }
186        let mut state = self
187            .0
188            .state
189            .lock()
190            .unwrap_or_else(std::sync::PoisonError::into_inner);
191        state.choice_revision = state.choice_revision.wrapping_add(1);
192        if state.preference != preference {
193            state.preference = preference;
194            self.0.revision.fetch_add(1, Ordering::Release);
195        }
196        Ok(())
197    }
198
199    /// Updates the ordered host preferences without changing an explicit choice.
200    pub fn refresh_system(&self, system: &[Locale]) -> bool {
201        let mut state = self
202            .0
203            .state
204            .lock()
205            .unwrap_or_else(std::sync::PoisonError::into_inner);
206        if state.system == system {
207            return false;
208        }
209        state.system.clear();
210        state.system.extend_from_slice(system);
211        self.0.revision.fetch_add(1, Ordering::Release);
212        true
213    }
214
215    /// Creates a formatter for the current application choice on this thread.
216    pub fn translator(&self) -> Translator {
217        let state = self
218            .0
219            .state
220            .lock()
221            .unwrap_or_else(std::sync::PoisonError::into_inner);
222        state.preference.translator(self.catalog(), &state.system)
223    }
224
225    /// Resolves a deferred message on any thread using its cached formatter.
226    pub fn format(&self, message: &DeferredMessage) -> Result<String, LocalizationError> {
227        crate::thread_state::with(|state| {
228            let mut cache = state.formatter.borrow_mut();
229            let revision = self.revision();
230            if !cache.as_ref().is_some_and(|cached| {
231                cached.owner.as_ptr() == Arc::as_ptr(&self.0) && cached.revision == revision
232            }) {
233                *cache = Some(CachedFormatter {
234                    owner: Arc::downgrade(&self.0),
235                    revision,
236                    translator: self.translator(),
237                });
238            }
239            message.format(cache.as_ref().map(|cached| &cached.translator))
240        })
241    }
242
243    /// Resolves native UI text, with readable source text if a runtime resource is malformed.
244    pub fn text(&self, message: &DeferredMessage) -> String {
245        self.format(message)
246            .unwrap_or_else(|_| message.fallback_text())
247    }
248}
249
250pub(crate) struct CachedFormatter {
251    owner: Weak<Data>,
252    revision: u64,
253    translator: Translator,
254}