Skip to main content

cranpose_ui/
language_preferences.rs

1use cranpose_core::{
2    CompositionLocal, CompositionLocalProvider, LaunchedEffect, MutableState, compositionLocalOf,
3    launchBlocking, rememberKeyed, rememberMutableStateOf,
4};
5
6use crate::localization::{Catalog, LanguagePreference, Locale, Localization, ProvideTranslator};
7
8#[derive(Clone, Default, PartialEq)]
9struct SelectionState {
10    revision: u64,
11    loading: bool,
12    saving: bool,
13    pending: Option<LanguagePreference>,
14    error: Option<String>,
15}
16
17/// Reactive application language state with asynchronous preference persistence.
18/// The controller retains at most one pending choice while a storage request runs.
19#[derive(Clone)]
20pub struct LocalizationController {
21    application: Localization,
22    state: MutableState<SelectionState>,
23}
24
25impl PartialEq for LocalizationController {
26    fn eq(&self, other: &Self) -> bool {
27        self.application == other.application
28    }
29}
30
31impl LocalizationController {
32    /// The current saved language choice. Reading it subscribes this component.
33    pub fn preference(&self) -> LanguagePreference {
34        let _revision = self.state.get().revision;
35        self.application.preference()
36    }
37
38    /// Supported languages and translations for this application.
39    pub fn catalog(&self) -> &Catalog {
40        self.application.catalog()
41    }
42
43    /// The shared handle for worker messages and native services.
44    pub fn application(&self) -> &Localization {
45        &self.application
46    }
47
48    /// Whether initial preferences or a selected choice await storage completion.
49    pub fn is_busy(&self) -> bool {
50        let state = self.state.get();
51        state.loading || state.saving
52    }
53
54    /// The latest preference-storage error, if a request failed.
55    pub fn error(&self) -> Option<String> {
56        self.state.get().error
57    }
58
59    /// Saves a language asynchronously, then updates all subscribed components.
60    /// If a request is active, only the most recent additional choice is retained.
61    pub fn select(&self, preference: LanguagePreference) {
62        if !self.application.is_persistent() {
63            let result = self.application.select(preference);
64            self.state.update(|state| {
65                state.revision = self.application.revision();
66                state.error = result.err().map(|error| error.to_string());
67            });
68            return;
69        }
70        self.state.update(|state| {
71            state.pending = Some(preference);
72            state.error = None;
73        });
74        self.save_next();
75    }
76
77    fn save_next(&self) {
78        let mut next = None;
79        self.state.update(|state| {
80            if !state.loading && !state.saving {
81                next = state.pending.take();
82                state.saving = next.is_some();
83            }
84        });
85        let Some(preference) = next else { return };
86        let application = self.application.clone();
87        let controller = self.clone();
88        let started = launchBlocking(
89            move || application.select(preference),
90            move |result| {
91                controller.state.update(|state| {
92                    state.saving = false;
93                    state.revision = controller.application.revision();
94                    state.error = storage_error(result);
95                });
96                controller.save_next();
97            },
98        );
99        if let Err(error) = started {
100            self.state.update(|state| {
101                state.saving = false;
102                state.error = Some(error.to_string());
103            });
104        }
105    }
106}
107
108/// The language controller installed by the nearest application provider.
109pub fn local_localization() -> CompositionLocal<Option<LocalizationController>> {
110    crate::environment_locals::cached_local(
111        |locals| &locals.localization_controller,
112        || compositionLocalOf(|| None),
113    )
114}
115
116/// Provides automatic language selection and stored overrides to a UI subtree.
117/// Native applications normally use `cranpose::ProvideApplicationLocalization`,
118/// which supplies and refreshes the host language list automatically.
119#[expect(non_snake_case)]
120#[track_caller]
121pub fn ProvideLanguagePreferences(
122    application: Localization,
123    system: &[Locale],
124    content: impl FnOnce(),
125) {
126    application.refresh_system(system);
127    let state = rememberMutableStateOf(|| SelectionState {
128        loading: application.is_persistent(),
129        ..SelectionState::default()
130    });
131    let controller = LocalizationController {
132        application: application.clone(),
133        state,
134    };
135    let loader = controller.clone();
136    LaunchedEffect(application.clone(), move |_| {
137        let application = loader.application.clone();
138        if !application.is_persistent() {
139            return;
140        }
141        let state = loader.state;
142        let started = launchBlocking(
143            move || application.load(),
144            move |result| {
145                loader.state.update(|state| {
146                    state.loading = false;
147                    state.revision = loader.application.revision();
148                    state.error = storage_error(result);
149                });
150                loader.save_next();
151            },
152        );
153        if let Err(error) = started {
154            state.update(|state| {
155                state.loading = false;
156                state.error = Some(error.to_string());
157            });
158        }
159    });
160    let _observed = state.get().revision;
161    let translator = rememberKeyed(
162        (application.clone(), application.revision()),
163        |(application, _)| application.translator(),
164    );
165    CompositionLocalProvider([local_localization().provides(Some(controller))], || {
166        ProvideTranslator(translator, content);
167    });
168}
169
170fn storage_error(
171    result: Result<
172        Result<(), crate::localization::LocalizationError>,
173        cranpose_core::BlockingError,
174    >,
175) -> Option<String> {
176    match result {
177        Ok(Ok(())) => None,
178        Ok(Err(error)) => Some(error.to_string()),
179        Err(error) => Some(error.to_string()),
180    }
181}