Skip to main content

cranpose_localization/
preferences.rs

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