Skip to main content

liora_core/
locales.rs

1//! Runtime locale helpers for Liora applications.
2//!
3//! Liora keeps locales deliberately low-coupling: component code asks for a stable
4//! key at render time, while applications choose where translation resources
5//! come from. The default implementation loads external TOML language files;
6//! advanced applications can provide any [`Translator`] implementation.
7
8use gpui::{App, Context, SharedString, Window};
9use std::{
10    collections::HashMap,
11    error::Error,
12    fmt, fs,
13    path::{Path, PathBuf},
14    sync::Arc,
15};
16
17/// Typed translation key used by [`tr`].
18///
19/// The value remains a dot-separated resource path internally so external TOML
20/// language files stay simple and low-coupling, but application/component code
21/// can use generated or macro-defined constants instead of hardcoded strings.
22#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)]
23pub struct Locales(&'static str);
24
25impl Locales {
26    /// Creates a typed key from a static resource path.
27    pub const fn new(key: &'static str) -> Self {
28        Self(key)
29    }
30
31    /// Returns the dot-separated resource path used by translation providers.
32    pub const fn as_str(self) -> &'static str {
33        self.0
34    }
35}
36
37impl AsRef<str> for Locales {
38    fn as_ref(&self) -> &str {
39        self.0
40    }
41}
42
43impl fmt::Display for Locales {
44    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
45        f.write_str(self.0)
46    }
47}
48
49/// Converts a typed locale key or generated locale-key function into [`Locales`].
50pub trait IntoLocalesKey {
51    /// Returns the typed locale key.
52    fn into_locales_key(self) -> Locales;
53}
54
55impl IntoLocalesKey for Locales {
56    fn into_locales_key(self) -> Locales {
57        self
58    }
59}
60
61impl<F> IntoLocalesKey for F
62where
63    F: FnOnce() -> Locales,
64{
65    fn into_locales_key(self) -> Locales {
66        self()
67    }
68}
69
70/// User-facing text that can be either a literal string or a typed locale key.
71///
72/// Component constructors accept this type through `impl Into<LocalizedText>`,
73/// so callers can pass generated keys directly, e.g.
74/// `Button::new(locales::common::ok)`. The actual translation is resolved at
75/// render time from the current [`LocalesContext`], which keeps runtime
76/// language switching working without hardcoded string keys at call sites.
77#[derive(Clone, Debug, PartialEq, Eq, Hash)]
78pub enum LocalizedText {
79    /// Literal user-facing text that should not be translated.
80    Literal(SharedString),
81    /// Typed locale key resolved against the active locale during render.
82    Key(Locales),
83}
84
85impl LocalizedText {
86    /// Creates a literal localized text source.
87    pub fn literal(text: impl Into<SharedString>) -> Self {
88        Self::Literal(text.into())
89    }
90
91    /// Creates a localized text source from a typed key.
92    pub const fn key(key: Locales) -> Self {
93        Self::Key(key)
94    }
95
96    /// Resolves this text source against the active locale context.
97    pub fn resolve(&self, cx: &impl LocalesContext) -> SharedString {
98        match self {
99            Self::Literal(text) => text.clone(),
100            Self::Key(key) => tr(cx, *key),
101        }
102    }
103
104    /// Returns a stable seed for element ids, tests, and debug output.
105    pub fn stable_seed(&self) -> &str {
106        match self {
107            Self::Literal(text) => text.as_ref(),
108            Self::Key(key) => key.as_str(),
109        }
110    }
111
112    /// Returns whether this text source is definitely empty before translation.
113    pub fn is_empty_source(&self) -> bool {
114        match self {
115            Self::Literal(text) => text.is_empty(),
116            Self::Key(_) => false,
117        }
118    }
119}
120
121impl From<Locales> for LocalizedText {
122    fn from(value: Locales) -> Self {
123        Self::Key(value)
124    }
125}
126
127impl<F> From<F> for LocalizedText
128where
129    F: FnOnce() -> Locales,
130{
131    fn from(value: F) -> Self {
132        Self::Key(value())
133    }
134}
135
136impl From<SharedString> for LocalizedText {
137    fn from(value: SharedString) -> Self {
138        Self::Literal(value)
139    }
140}
141
142impl From<&'static str> for LocalizedText {
143    fn from(value: &'static str) -> Self {
144        Self::Literal(value.into())
145    }
146}
147
148impl From<String> for LocalizedText {
149    fn from(value: String) -> Self {
150        Self::Literal(value.into())
151    }
152}
153
154/// Defines typed locale key constants grouped by resource table.
155///
156/// Applications normally use the generated `locales` module, which is rebuilt
157/// by Cargo from `assets/locales/*.toml`. This macro remains available for
158/// application-specific key modules outside Liora's default resources.
159///
160/// ```
161/// liora_core::locales! {
162///     pub mod app_keys {
163///         window { title }
164///     }
165/// }
166/// # let _ = app_keys::window::title;
167/// ```
168#[macro_export]
169macro_rules! locales {
170    (
171        $vis:vis mod $module:ident {
172            $(
173                $group:ident { $($key:ident),+ $(,)? }
174            )+
175        }
176    ) => {
177        $vis mod $module {
178            $crate::locales!(@groups pub, $( $group { $($key),+ } )+);
179        }
180    };
181    (@groups $vis:vis, $( $group:ident { $($key:ident),+ } )+) => {
182        $(
183            $vis mod $group {
184                $(
185                    #[doc = concat!("Returns locale key `", stringify!($group), ".", stringify!($key), "`.")]
186                    pub const fn $key() -> $crate::locales::Locales {
187                        $crate::locales::Locales::new(concat!(stringify!($group), ".", stringify!($key)))
188                    }
189                )+
190            }
191        )+
192    };
193}
194
195include!(concat!(env!("OUT_DIR"), "/locales_keys.rs"));
196
197/// Stable locale identifier such as `"zh-CN"` or `"en-US"`.
198#[derive(Clone, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)]
199pub struct LocaleId(SharedString);
200
201impl LocaleId {
202    /// Creates a locale identifier from an application-supplied string.
203    pub fn new(locale: impl Into<SharedString>) -> Self {
204        Self(locale.into())
205    }
206
207    /// Returns the underlying locale string.
208    pub fn as_str(&self) -> &str {
209        self.0.as_ref()
210    }
211}
212
213impl Default for LocaleId {
214    fn default() -> Self {
215        Self::new("en-US")
216    }
217}
218
219impl From<&str> for LocaleId {
220    fn from(value: &str) -> Self {
221        Self::new(value)
222    }
223}
224
225impl From<String> for LocaleId {
226    fn from(value: String) -> Self {
227        Self::new(value)
228    }
229}
230
231impl From<SharedString> for LocaleId {
232    fn from(value: SharedString) -> Self {
233        Self::new(value)
234    }
235}
236
237impl fmt::Display for LocaleId {
238    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
239        f.write_str(self.as_str())
240    }
241}
242
243/// Text direction associated with a locale.
244#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
245pub enum TextDirection {
246    /// Left-to-right writing direction.
247    #[default]
248    Ltr,
249    /// Right-to-left writing direction.
250    Rtl,
251}
252
253/// Pluggable translation provider.
254///
255/// Applications can replace the default TOML-backed map with a custom provider
256/// that reads from a database, remote service, Fluent/ICU runtime, or any other
257/// source. Components depend only on this trait through [`tr`].
258pub trait Translator: Send + Sync {
259    /// Returns a translated value for `key` under `locale`, or `None` when the
260    /// provider does not know that key.
261    fn translate(&self, locale: &LocaleId, key: &str) -> Option<SharedString>;
262
263    /// Returns whether this provider has any resources for `locale`.
264    fn has_locale(&self, _locale: &LocaleId) -> bool {
265        false
266    }
267}
268
269/// In-memory translator loaded from external language files or explicit maps.
270#[derive(Clone, Debug, Default, PartialEq, Eq)]
271pub struct LocalesMap {
272    locales: HashMap<LocaleId, HashMap<SharedString, SharedString>>,
273}
274
275impl LocalesMap {
276    /// Creates an empty translation map.
277    pub fn new() -> Self {
278        Self::default()
279    }
280
281    /// Creates a map containing Liora's small built-in safety fallback.
282    pub fn builtin() -> Self {
283        let mut map = Self::new();
284        map = map.with_locale("en-US", builtin_locale_entries(BUILTIN_EN_US_TOML));
285        map = map.with_locale("zh-CN", builtin_locale_entries(BUILTIN_ZH_CN_TOML));
286        map
287    }
288
289    /// Adds or replaces a locale with key/value pairs.
290    pub fn with_locale(
291        mut self,
292        locale: impl Into<LocaleId>,
293        entries: impl IntoIterator<Item = (impl Into<SharedString>, impl Into<SharedString>)>,
294    ) -> Self {
295        self.insert_locale(locale, entries);
296        self
297    }
298
299    /// Adds or replaces a locale with key/value pairs.
300    pub fn insert_locale(
301        &mut self,
302        locale: impl Into<LocaleId>,
303        entries: impl IntoIterator<Item = (impl Into<SharedString>, impl Into<SharedString>)>,
304    ) {
305        self.locales.insert(
306            locale.into(),
307            entries
308                .into_iter()
309                .map(|(key, value)| (key.into(), value.into()))
310                .collect(),
311        );
312    }
313
314    /// Overrides entries in a locale while preserving all other locale keys.
315    pub fn override_locale(
316        mut self,
317        locale: impl Into<LocaleId>,
318        entries: impl IntoIterator<Item = (impl Into<SharedString>, impl Into<SharedString>)>,
319    ) -> Self {
320        let values = self.locales.entry(locale.into()).or_default();
321        for (key, value) in entries {
322            values.insert(key.into(), value.into());
323        }
324        self
325    }
326
327    /// Loads one locale from a TOML file.
328    pub fn load_locale_file(
329        &mut self,
330        locale: impl Into<LocaleId>,
331        path: impl AsRef<Path>,
332    ) -> Result<(), LocalesLoadError> {
333        let path = path.as_ref();
334        let content = fs::read_to_string(path).map_err(|source| LocalesLoadError::Io {
335            path: path.to_path_buf(),
336            source,
337        })?;
338        let entries =
339            parse_toml_translations(&content).map_err(|source| LocalesLoadError::Parse {
340                path: path.to_path_buf(),
341                source,
342            })?;
343        self.insert_locale(locale, entries);
344        Ok(())
345    }
346
347    /// Loads all `*.toml` files from a directory, using file stems as locale ids.
348    pub fn load_dir(&mut self, dir: impl AsRef<Path>) -> Result<Vec<LocaleId>, LocalesLoadError> {
349        let dir = dir.as_ref();
350        let mut loaded = Vec::new();
351        let entries = fs::read_dir(dir).map_err(|source| LocalesLoadError::Io {
352            path: dir.to_path_buf(),
353            source,
354        })?;
355        let mut files = Vec::new();
356        for entry in entries {
357            let entry = entry.map_err(|source| LocalesLoadError::Io {
358                path: dir.to_path_buf(),
359                source,
360            })?;
361            let path = entry.path();
362            if path.extension().and_then(|ext| ext.to_str()) == Some("toml") {
363                files.push(path);
364            }
365        }
366        files.sort();
367        for path in files {
368            let Some(locale) = path.file_stem().and_then(|stem| stem.to_str()) else {
369                continue;
370            };
371            let locale = LocaleId::from(locale);
372            self.load_locale_file(locale.clone(), &path)?;
373            loaded.push(locale);
374        }
375        Ok(loaded)
376    }
377
378    /// Returns true when this map contains any entries for `locale`.
379    pub fn has_locale(&self, locale: &LocaleId) -> bool {
380        self.locales.contains_key(locale)
381    }
382
383    /// Returns the loaded locale ids.
384    pub fn locales(&self) -> impl Iterator<Item = &LocaleId> {
385        self.locales.keys()
386    }
387}
388
389impl Translator for LocalesMap {
390    fn translate(&self, locale: &LocaleId, key: &str) -> Option<SharedString> {
391        self.locales
392            .get(locale)
393            .and_then(|entries| entries.get(key).cloned())
394    }
395
396    fn has_locale(&self, locale: &LocaleId) -> bool {
397        self.has_locale(locale)
398    }
399}
400
401/// Complete locale runtime config.
402#[derive(Clone)]
403pub struct LocalesConfig {
404    /// Currently active locale.
405    pub locale: LocaleId,
406    /// Fallback locale used when the active locale misses a key.
407    pub fallback_locale: LocaleId,
408    /// Text direction reserved for direction-aware components and shells.
409    pub direction: TextDirection,
410    /// Translation resources loaded from external TOML files or explicit maps.
411    pub resources: LocalesMap,
412    /// Optional application-provided translator. When present it is consulted
413    /// before the file-backed resources so apps can fully override Liora's
414    /// lookup behavior without forcing components to know about that system.
415    pub translator: Option<Arc<dyn Translator>>,
416    /// Optional external locales directory used by [`switch_locale_from_dir`].
417    pub resource_dir: Option<PathBuf>,
418    /// Monotonic version incremented whenever locale/resources change.
419    pub version: u64,
420}
421
422impl Default for LocalesConfig {
423    fn default() -> Self {
424        Self {
425            locale: LocaleId::from("en-US"),
426            fallback_locale: LocaleId::from("en-US"),
427            direction: TextDirection::Ltr,
428            resources: LocalesMap::builtin(),
429            translator: None,
430            resource_dir: None,
431            version: 0,
432        }
433    }
434}
435
436impl fmt::Debug for LocalesConfig {
437    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
438        f.debug_struct("LocalesConfig")
439            .field("locale", &self.locale)
440            .field("fallback_locale", &self.fallback_locale)
441            .field("direction", &self.direction)
442            .field("resource_dir", &self.resource_dir)
443            .field("version", &self.version)
444            .finish_non_exhaustive()
445    }
446}
447
448impl PartialEq for LocalesConfig {
449    fn eq(&self, other: &Self) -> bool {
450        self.locale == other.locale
451            && self.fallback_locale == other.fallback_locale
452            && self.direction == other.direction
453            && self.resources == other.resources
454            && self.resource_dir == other.resource_dir
455            && self.version == other.version
456            && match (&self.translator, &other.translator) {
457                (Some(a), Some(b)) => Arc::ptr_eq(a, b),
458                (None, None) => true,
459                _ => false,
460            }
461    }
462}
463
464impl Eq for LocalesConfig {}
465
466impl LocalesConfig {
467    /// Creates the default locales config.
468    pub fn system() -> Self {
469        Self::default()
470    }
471
472    /// Returns a config with an active locale.
473    pub fn with_locale(mut self, locale: impl Into<LocaleId>) -> Self {
474        self.locale = locale.into();
475        self.direction = direction_for_locale(&self.locale);
476        self
477    }
478
479    /// Returns a config with a fallback locale.
480    pub fn with_fallback_locale(mut self, locale: impl Into<LocaleId>) -> Self {
481        self.fallback_locale = locale.into();
482        self
483    }
484
485    /// Returns a config with a text direction.
486    pub fn with_direction(mut self, direction: TextDirection) -> Self {
487        self.direction = direction;
488        self
489    }
490
491    /// Returns a config with file-backed resources.
492    pub fn with_resources(mut self, resources: LocalesMap) -> Self {
493        self.resources = resources;
494        self
495    }
496
497    /// Loads all TOML files from `dir` into this config and remembers the dir
498    /// for later runtime locale loading.
499    pub fn try_with_locales_dir(mut self, dir: impl AsRef<Path>) -> Result<Self, LocalesLoadError> {
500        let dir = dir.as_ref();
501        self.resources.load_dir(dir)?;
502        self.resource_dir = Some(dir.to_path_buf());
503        Ok(self)
504    }
505
506    /// Returns a config using a custom translator implementation.
507    pub fn with_translator(mut self, translator: impl Translator + 'static) -> Self {
508        self.translator = Some(Arc::new(translator));
509        self
510    }
511
512    /// Returns a config using a shared custom translator implementation.
513    pub fn with_shared_translator(mut self, translator: Arc<dyn Translator>) -> Self {
514        self.translator = Some(translator);
515        self
516    }
517
518    /// Resolves a translation with active-locale and fallback-locale lookup.
519    pub fn translate(&self, key: &str) -> SharedString {
520        self.translator
521            .as_ref()
522            .and_then(|translator| translator.translate(&self.locale, key))
523            .or_else(|| {
524                self.translator
525                    .as_ref()
526                    .and_then(|translator| translator.translate(&self.fallback_locale, key))
527            })
528            .or_else(|| self.resources.translate(&self.locale, key))
529            .or_else(|| self.resources.translate(&self.fallback_locale, key))
530            .or_else(|| builtin_locale_value(BUILTIN_EN_US_TOML, key))
531            .unwrap_or_else(|| key.into())
532    }
533
534    /// Returns true when either configured provider has resources for `locale`.
535    pub fn has_locale(&self, locale: &LocaleId) -> bool {
536        self.translator
537            .as_ref()
538            .is_some_and(|translator| translator.has_locale(locale))
539            || self.resources.has_locale(locale)
540    }
541}
542
543/// Error returned while reading external language resources.
544#[derive(Debug)]
545pub enum LocalesLoadError {
546    /// Failed to read a file or directory.
547    Io {
548        /// Path being read.
549        path: PathBuf,
550        /// Source IO error.
551        source: std::io::Error,
552    },
553    /// Failed to parse a TOML language file.
554    Parse {
555        /// File being parsed.
556        path: PathBuf,
557        /// Source TOML parser error.
558        source: toml::de::Error,
559    },
560    /// A requested locale does not exist in the configured locales directory.
561    MissingLocaleFile {
562        /// Locale that was requested.
563        locale: LocaleId,
564        /// Expected TOML file path.
565        path: PathBuf,
566    },
567}
568
569impl fmt::Display for LocalesLoadError {
570    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
571        match self {
572            Self::Io { path, source } => write!(
573                f,
574                "failed to read locale resource {}: {source}",
575                path.display()
576            ),
577            Self::Parse { path, source } => write!(
578                f,
579                "failed to parse locale resource {}: {source}",
580                path.display()
581            ),
582            Self::MissingLocaleFile { locale, path } => {
583                write!(f, "missing locale resource {locale} at {}", path.display())
584            }
585        }
586    }
587}
588
589impl Error for LocalesLoadError {
590    fn source(&self) -> Option<&(dyn Error + 'static)> {
591        match self {
592            Self::Io { source, .. } => Some(source),
593            Self::Parse { source, .. } => Some(source),
594            Self::MissingLocaleFile { .. } => None,
595        }
596    }
597}
598
599/// Flattens a TOML language file into dot-separated translation keys.
600pub fn parse_toml_translations(
601    source: &str,
602) -> Result<Vec<(SharedString, SharedString)>, toml::de::Error> {
603    let value: toml::Value = toml::from_str(source)?;
604    let mut out = Vec::new();
605    flatten_toml_value(None, &value, &mut out);
606    out.sort_by(|(a, _), (b, _)| a.as_ref().cmp(b.as_ref()));
607    Ok(out)
608}
609
610fn flatten_toml_value(
611    prefix: Option<&str>,
612    value: &toml::Value,
613    out: &mut Vec<(SharedString, SharedString)>,
614) {
615    match value {
616        toml::Value::String(text) => {
617            if let Some(prefix) = prefix {
618                out.push((prefix.into(), text.as_str().into()));
619            }
620        }
621        toml::Value::Table(table) => {
622            for (key, value) in table {
623                let next = if let Some(prefix) = prefix {
624                    format!("{prefix}.{key}")
625                } else {
626                    key.clone()
627                };
628                flatten_toml_value(Some(&next), value, out);
629            }
630        }
631        _ => {}
632    }
633}
634
635const BUILTIN_EN_US_TOML: &str = include_str!("../assets/locales/en-US.toml");
636const BUILTIN_ZH_CN_TOML: &str = include_str!("../assets/locales/zh-CN.toml");
637
638fn builtin_locale_entries(source: &'static str) -> Vec<(SharedString, SharedString)> {
639    parse_toml_translations(source).expect("built-in locale resources must be valid TOML")
640}
641
642fn builtin_locale_value(source: &'static str, key: &str) -> Option<SharedString> {
643    builtin_locale_entries(source)
644        .into_iter()
645        .find_map(|(entry_key, value)| (entry_key.as_ref() == key).then_some(value))
646}
647
648/// Minimal context abstraction used by translation helpers.
649pub trait LocalesContext {
650    /// Returns the active locale config.
651    fn locales_config(&self) -> &LocalesConfig;
652}
653
654impl LocalesContext for LocalesConfig {
655    fn locales_config(&self) -> &LocalesConfig {
656        self
657    }
658}
659
660impl LocalesContext for App {
661    fn locales_config(&self) -> &LocalesConfig {
662        &self.global::<crate::Config>().locales
663    }
664}
665
666impl<T> LocalesContext for Context<'_, T> {
667    fn locales_config(&self) -> &LocalesConfig {
668        &self.global::<crate::Config>().locales
669    }
670}
671
672/// Returns the active locale.
673pub fn current_locale(cx: &impl LocalesContext) -> LocaleId {
674    cx.locales_config().locale.clone()
675}
676
677/// Returns the configured fallback locale.
678pub fn fallback_locale(cx: &impl LocalesContext) -> LocaleId {
679    cx.locales_config().fallback_locale.clone()
680}
681
682/// Returns the active locale version.
683pub fn locales_version(cx: &impl LocalesContext) -> u64 {
684    cx.locales_config().version
685}
686
687/// Translates `key` using the active locale config.
688pub fn tr(cx: &impl LocalesContext, key: impl IntoLocalesKey) -> SharedString {
689    cx.locales_config()
690        .translate(key.into_locales_key().as_str())
691}
692
693/// Replaces the complete locale config.
694pub fn set_locales_config(cx: &mut App, mut locales: LocalesConfig) {
695    locales.version = cx
696        .global::<crate::Config>()
697        .locales
698        .version
699        .saturating_add(1);
700    cx.global_mut::<crate::Config>().locales = locales;
701}
702
703/// Replaces the current custom translator while preserving locale and file resources.
704pub fn set_translator(cx: &mut App, translator: impl Translator + 'static) {
705    let config = cx.global_mut::<crate::Config>();
706    config.locales.translator = Some(Arc::new(translator));
707    config.locales.version = config.locales.version.saturating_add(1);
708}
709
710/// Replaces the current shared custom translator while preserving locale and file resources.
711pub fn set_shared_translator(cx: &mut App, translator: Arc<dyn Translator>) {
712    let config = cx.global_mut::<crate::Config>();
713    config.locales.translator = Some(translator);
714    config.locales.version = config.locales.version.saturating_add(1);
715}
716
717/// Clears the custom translator and uses only file-backed/built-in resources.
718pub fn clear_translator(cx: &mut App) {
719    let config = cx.global_mut::<crate::Config>();
720    config.locales.translator = None;
721    config.locales.version = config.locales.version.saturating_add(1);
722}
723
724/// Updates the active locale without refreshing a window.
725pub fn set_locale(cx: &mut App, locale: impl Into<LocaleId>) -> Result<(), LocalesLoadError> {
726    let locale = locale.into();
727    let config = cx.global_mut::<crate::Config>();
728    config.locales.locale = locale;
729    config.locales.direction = direction_for_locale(&config.locales.locale);
730    config.locales.version = config.locales.version.saturating_add(1);
731    Ok(())
732}
733
734/// Updates the active locale and refreshes the current window immediately.
735pub fn apply_locale(
736    window: &mut Window,
737    cx: &mut App,
738    locale: impl Into<LocaleId>,
739) -> Result<(), LocalesLoadError> {
740    set_locale(cx, locale)?;
741    window.refresh();
742    Ok(())
743}
744
745/// Loads one TOML locale file into the current file-backed resources.
746pub fn load_locale_file(
747    cx: &mut App,
748    locale: impl Into<LocaleId>,
749    path: impl AsRef<Path>,
750) -> Result<(), LocalesLoadError> {
751    let config = cx.global_mut::<crate::Config>();
752    config.locales.resources.load_locale_file(locale, path)?;
753    config.locales.version = config.locales.version.saturating_add(1);
754    Ok(())
755}
756
757/// Loads every TOML file from `dir` into the current file-backed resources.
758pub fn load_locales_dir(
759    cx: &mut App,
760    dir: impl AsRef<Path>,
761) -> Result<Vec<LocaleId>, LocalesLoadError> {
762    let dir = dir.as_ref();
763    let config = cx.global_mut::<crate::Config>();
764    let loaded = config.locales.resources.load_dir(dir)?;
765    config.locales.resource_dir = Some(dir.to_path_buf());
766    config.locales.version = config.locales.version.saturating_add(1);
767    Ok(loaded)
768}
769
770/// Ensures `locale` is loaded from `dir`, switches to it, and refreshes `window`.
771pub fn switch_locale_from_dir(
772    window: &mut Window,
773    cx: &mut App,
774    locale: impl Into<LocaleId>,
775    dir: impl AsRef<Path>,
776) -> Result<(), LocalesLoadError> {
777    let locale = locale.into();
778    if !cx.global::<crate::Config>().locales.has_locale(&locale) {
779        let path = dir.as_ref().join(format!("{}.toml", locale.as_str()));
780        if !path.exists() {
781            return Err(LocalesLoadError::MissingLocaleFile { locale, path });
782        }
783        load_locale_file(cx, locale.clone(), path)?;
784    }
785    apply_locale(window, cx, locale)
786}
787
788/// Returns a direction guess for a locale.
789pub fn direction_for_locale(locale: &LocaleId) -> TextDirection {
790    let language = locale
791        .as_str()
792        .split(['-', '_'])
793        .next()
794        .unwrap_or_default()
795        .to_ascii_lowercase();
796    match language.as_str() {
797        "ar" | "fa" | "he" | "ur" => TextDirection::Rtl,
798        _ => TextDirection::Ltr,
799    }
800}
801
802#[cfg(test)]
803mod tests {
804    use super::*;
805    use std::time::{SystemTime, UNIX_EPOCH};
806
807    crate::locales! {
808        mod test_keys {
809            demo { title, empty_state }
810        }
811    }
812
813    #[test]
814    fn typed_locales_preserve_dot_paths() {
815        assert_eq!(empty::description().as_str(), "empty.description");
816        assert_eq!(message_box::confirm().as_str(), "message_box.confirm");
817        assert_eq!(test_keys::demo::title().as_str(), "demo.title");
818        assert_eq!(test_keys::demo::empty_state().as_str(), "demo.empty_state");
819    }
820
821    #[test]
822    fn toml_translations_flatten_nested_tables() {
823        let entries = parse_toml_translations(
824            r#"
825[demo]
826ready = "Ready"
827[select]
828no_data = "No data"
829"#,
830        )
831        .unwrap();
832        assert!(entries.contains(&("demo.ready".into(), "Ready".into())));
833        assert!(entries.contains(&("select.no_data".into(), "No data".into())));
834    }
835
836    #[test]
837    fn locales_map_uses_locale_specific_values() {
838        let map = LocalesMap::new().with_locale("zh-CN", [("test.only", "测试")]);
839        assert_eq!(
840            map.translate(&LocaleId::from("zh-CN"), "test.only")
841                .as_deref(),
842            Some("测试")
843        );
844        assert_eq!(
845            map.translate(&LocaleId::from("en-US"), "test.only")
846                .as_deref(),
847            None
848        );
849    }
850
851    #[test]
852    fn locales_config_falls_back_to_fallback_locale_then_builtin_then_key() {
853        let map = LocalesMap::new().with_locale("en-US", [("demo.hello", "Hello")]);
854        let config = LocalesConfig::system()
855            .with_locale("zh-CN")
856            .with_fallback_locale("en-US")
857            .with_translator(map);
858        assert_eq!(config.translate("demo.hello").as_ref(), "Hello");
859        assert_eq!(config.translate("common.cancel").as_ref(), "取消");
860        assert_eq!(config.translate("missing.key").as_ref(), "missing.key");
861    }
862
863    #[test]
864    fn load_dir_uses_file_stems_as_locale_ids() {
865        let dir = temp_dir("liora-locales");
866        fs::create_dir_all(&dir).unwrap();
867        fs::write(dir.join("en-US.toml"), "[test]\nonly = \"Test\"\n").unwrap();
868        fs::write(dir.join("zh-CN.toml"), "[test]\nonly = \"测试\"\n").unwrap();
869
870        let mut map = LocalesMap::new();
871        let loaded = map.load_dir(&dir).unwrap();
872        assert_eq!(loaded.len(), 2);
873        assert_eq!(
874            map.translate(&LocaleId::from("zh-CN"), "test.only")
875                .as_deref(),
876            Some("测试")
877        );
878
879        fs::remove_dir_all(dir).unwrap();
880    }
881
882    #[test]
883    fn direction_detects_rtl_language_prefixes() {
884        assert_eq!(
885            direction_for_locale(&LocaleId::from("ar-SA")),
886            TextDirection::Rtl
887        );
888        assert_eq!(
889            direction_for_locale(&LocaleId::from("zh-CN")),
890            TextDirection::Ltr
891        );
892    }
893
894    fn temp_dir(label: &str) -> PathBuf {
895        let unique = SystemTime::now()
896            .duration_since(UNIX_EPOCH)
897            .unwrap()
898            .as_nanos();
899        std::env::temp_dir().join(format!("{label}-{unique}"))
900    }
901}