1use 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#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)]
23pub struct Locales(&'static str);
24
25impl Locales {
26 pub const fn new(key: &'static str) -> Self {
28 Self(key)
29 }
30
31 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
49pub trait IntoLocalesKey {
51 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#[derive(Clone, Debug, PartialEq, Eq, Hash)]
78pub enum LocalizedText {
79 Literal(SharedString),
81 Key(Locales),
83}
84
85impl LocalizedText {
86 pub fn literal(text: impl Into<SharedString>) -> Self {
88 Self::Literal(text.into())
89 }
90
91 pub const fn key(key: Locales) -> Self {
93 Self::Key(key)
94 }
95
96 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 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 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#[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#[derive(Clone, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)]
199pub struct LocaleId(SharedString);
200
201impl LocaleId {
202 pub fn new(locale: impl Into<SharedString>) -> Self {
204 Self(locale.into())
205 }
206
207 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#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
245pub enum TextDirection {
246 #[default]
248 Ltr,
249 Rtl,
251}
252
253pub trait Translator: Send + Sync {
259 fn translate(&self, locale: &LocaleId, key: &str) -> Option<SharedString>;
262
263 fn has_locale(&self, _locale: &LocaleId) -> bool {
265 false
266 }
267}
268
269#[derive(Clone, Debug, Default, PartialEq, Eq)]
271pub struct LocalesMap {
272 locales: HashMap<LocaleId, HashMap<SharedString, SharedString>>,
273}
274
275impl LocalesMap {
276 pub fn new() -> Self {
278 Self::default()
279 }
280
281 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 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 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 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 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 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 pub fn has_locale(&self, locale: &LocaleId) -> bool {
380 self.locales.contains_key(locale)
381 }
382
383 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#[derive(Clone)]
403pub struct LocalesConfig {
404 pub locale: LocaleId,
406 pub fallback_locale: LocaleId,
408 pub direction: TextDirection,
410 pub resources: LocalesMap,
412 pub translator: Option<Arc<dyn Translator>>,
416 pub resource_dir: Option<PathBuf>,
418 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 pub fn system() -> Self {
469 Self::default()
470 }
471
472 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 pub fn with_fallback_locale(mut self, locale: impl Into<LocaleId>) -> Self {
481 self.fallback_locale = locale.into();
482 self
483 }
484
485 pub fn with_direction(mut self, direction: TextDirection) -> Self {
487 self.direction = direction;
488 self
489 }
490
491 pub fn with_resources(mut self, resources: LocalesMap) -> Self {
493 self.resources = resources;
494 self
495 }
496
497 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 pub fn with_translator(mut self, translator: impl Translator + 'static) -> Self {
508 self.translator = Some(Arc::new(translator));
509 self
510 }
511
512 pub fn with_shared_translator(mut self, translator: Arc<dyn Translator>) -> Self {
514 self.translator = Some(translator);
515 self
516 }
517
518 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 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#[derive(Debug)]
545pub enum LocalesLoadError {
546 Io {
548 path: PathBuf,
550 source: std::io::Error,
552 },
553 Parse {
555 path: PathBuf,
557 source: toml::de::Error,
559 },
560 MissingLocaleFile {
562 locale: LocaleId,
564 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
599pub 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
648pub trait LocalesContext {
650 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
672pub fn current_locale(cx: &impl LocalesContext) -> LocaleId {
674 cx.locales_config().locale.clone()
675}
676
677pub fn fallback_locale(cx: &impl LocalesContext) -> LocaleId {
679 cx.locales_config().fallback_locale.clone()
680}
681
682pub fn locales_version(cx: &impl LocalesContext) -> u64 {
684 cx.locales_config().version
685}
686
687pub fn tr(cx: &impl LocalesContext, key: impl IntoLocalesKey) -> SharedString {
689 cx.locales_config()
690 .translate(key.into_locales_key().as_str())
691}
692
693pub 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
703pub 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
710pub 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
717pub 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
724pub 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
734pub 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
745pub 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
757pub 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
770pub 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
788pub 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}