Skip to main content

cranpose_localization/
preferences.rs

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