Skip to main content

i_slint_core/
translations.rs

1// Copyright © SixtyFPS GmbH <info@slint.dev>
2// SPDX-License-Identifier: GPL-3.0-only OR LicenseRef-Slint-Royalty-free-2.0 OR LicenseRef-Slint-Software-3.0
3
4// cSpell: ignore cntr demangle indice
5use crate::SharedString;
6use core::fmt::Display;
7pub use formatter::FormatArgs;
8use i_slint_common::TranslationsBundled;
9#[cfg(feature = "tr")]
10pub use tr::Translator;
11
12mod formatter {
13    use core::fmt::{Display, Formatter, Result};
14
15    pub trait FormatArgs {
16        type Output<'a>: Display
17        where
18            Self: 'a;
19        #[allow(clippy::wrong_self_convention)]
20        fn from_index(&self, index: usize) -> Option<Self::Output<'_>>;
21        #[allow(clippy::wrong_self_convention)]
22        fn from_name(&self, _name: &str) -> Option<Self::Output<'_>> {
23            None
24        }
25    }
26
27    impl<T: Display> FormatArgs for [T] {
28        type Output<'a>
29            = &'a T
30        where
31            T: 'a;
32        fn from_index(&self, index: usize) -> Option<&T> {
33            self.get(index)
34        }
35    }
36
37    impl<const N: usize, T: Display> FormatArgs for [T; N] {
38        type Output<'a>
39            = &'a T
40        where
41            T: 'a;
42        fn from_index(&self, index: usize) -> Option<&T> {
43            self.get(index)
44        }
45    }
46
47    pub fn format<'a>(
48        format_str: &'a str,
49        args: &'a (impl FormatArgs + ?Sized),
50    ) -> impl Display + 'a {
51        FormatResult { format_str, args }
52    }
53
54    struct FormatResult<'a, T: ?Sized> {
55        format_str: &'a str,
56        args: &'a T,
57    }
58
59    impl<T: FormatArgs + ?Sized> Display for FormatResult<'_, T> {
60        fn fmt(&self, f: &mut Formatter<'_>) -> Result {
61            let mut arg_idx = 0;
62            let mut pos = 0;
63            while let Some(mut p) = self.format_str[pos..].find(['{', '}']) {
64                if self.format_str.len() - pos < p + 1 {
65                    break;
66                }
67                p += pos;
68
69                // Skip escaped }
70                if self.format_str.get(p..=p) == Some("}") {
71                    self.format_str[pos..=p].fmt(f)?;
72                    if self.format_str.get(p + 1..=p + 1) == Some("}") {
73                        pos = p + 2;
74                    } else {
75                        // FIXME! this is an error, it should be reported  ('}' must be escaped)
76                        pos = p + 1;
77                    }
78                    continue;
79                }
80
81                // Skip escaped {
82                if self.format_str.get(p + 1..=p + 1) == Some("{") {
83                    self.format_str[pos..=p].fmt(f)?;
84                    pos = p + 2;
85                    continue;
86                }
87
88                // Find the argument
89                let end = if let Some(end) = self.format_str[p..].find('}') {
90                    end + p
91                } else {
92                    // FIXME! this is an error, it should be reported
93                    self.format_str[pos..=p].fmt(f)?;
94                    pos = p + 1;
95                    continue;
96                };
97                let argument = self.format_str[p + 1..end].trim();
98                let pa = if p == end - 1 {
99                    arg_idx += 1;
100                    self.args.from_index(arg_idx - 1)
101                } else if let Ok(n) = argument.parse::<usize>() {
102                    self.args.from_index(n)
103                } else {
104                    self.args.from_name(argument)
105                };
106
107                // format the part before the '{'
108                self.format_str[pos..p].fmt(f)?;
109                if let Some(a) = pa {
110                    a.fmt(f)?;
111                } else {
112                    // FIXME! this is an error, it should be reported
113                    self.format_str[p..=end].fmt(f)?;
114                }
115                pos = end + 1;
116            }
117            self.format_str[pos..].fmt(f)
118        }
119    }
120
121    #[cfg(test)]
122    mod tests {
123        use super::format;
124        use core::fmt::Display;
125        use std::string::{String, ToString};
126        #[test]
127        fn test_format() {
128            assert_eq!(format("Hello", (&[]) as &[String]).to_string(), "Hello");
129            assert_eq!(format("Hello {}!", &["world"]).to_string(), "Hello world!");
130            assert_eq!(format("Hello {0}!", &["world"]).to_string(), "Hello world!");
131            assert_eq!(
132                format("Hello -{1}- -{0}-", &[&(40 + 5) as &dyn Display, &"World"]).to_string(),
133                "Hello -World- -45-"
134            );
135            assert_eq!(
136                format(
137                    format("Hello {{}}!", (&[]) as &[String]).to_string().as_str(),
138                    &[format("{}", &["world"])]
139                )
140                .to_string(),
141                "Hello world!"
142            );
143            assert_eq!(
144                format("Hello -{}- -{}-", &[&(40 + 5) as &dyn Display, &"World"]).to_string(),
145                "Hello -45- -World-"
146            );
147            assert_eq!(format("Hello {{0}} {}", &["world"]).to_string(), "Hello {0} world");
148        }
149    }
150}
151
152struct WithPlural<'a, T: ?Sized>(&'a T, i32);
153
154enum DisplayOrInt<T> {
155    Display(T),
156    Int(i32),
157}
158impl<T: Display> Display for DisplayOrInt<T> {
159    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
160        match self {
161            DisplayOrInt::Display(d) => d.fmt(f),
162            DisplayOrInt::Int(i) => i.fmt(f),
163        }
164    }
165}
166
167impl<T: FormatArgs + ?Sized> FormatArgs for WithPlural<'_, T> {
168    type Output<'b>
169        = DisplayOrInt<T::Output<'b>>
170    where
171        Self: 'b;
172
173    fn from_index(&self, index: usize) -> Option<Self::Output<'_>> {
174        self.0.from_index(index).map(DisplayOrInt::Display)
175    }
176
177    fn from_name<'b>(&'b self, name: &str) -> Option<Self::Output<'b>> {
178        if name == "n" {
179            Some(DisplayOrInt::Int(self.1))
180        } else {
181            self.0.from_name(name).map(DisplayOrInt::Display)
182        }
183    }
184}
185
186/// Do the translation and formatting
187pub fn translate(
188    original: &str,
189    contextid: &str,
190    domain: &str,
191    arguments: &(impl FormatArgs + ?Sized),
192    n: i32,
193    plural: &str,
194) -> SharedString {
195    #![allow(unused)]
196    let mut output = SharedString::default();
197
198    // Register a dependency so that language changes trigger a re-evaluation of all relevant bindings
199    // and this function is called again.
200    #[cfg(any(feature = "tr", all(target_family = "unix", feature = "gettext-rs")))]
201    global_translation_property();
202
203    let mut translated: Option<alloc::borrow::Cow<'_, str>> = None;
204
205    #[cfg(feature = "tr")]
206    {
207        translated = crate::context::GLOBAL_CONTEXT.with(|ctx| {
208            let ctx = ctx.get()?;
209            let external_translator = ctx.external_translator()?;
210            let context = if !contextid.is_empty() { Some(contextid) } else { None };
211            Some(
212                if plural.is_empty() {
213                    external_translator.translate(original, context)
214                } else {
215                    external_translator.ntranslate(n.try_into().ok()?, original, plural, context)
216                }
217                .into_owned()
218                .into(),
219            )
220        });
221    }
222
223    #[cfg(all(target_family = "unix", feature = "gettext-rs"))]
224    if translated.is_none() {
225        translated = Some(alloc::borrow::Cow::Owned(translate_gettext(
226            original, contextid, domain, n, plural,
227        )));
228    }
229
230    let translated = translated
231        .unwrap_or_else(|| if plural.is_empty() || n == 1 { original } else { plural }.into());
232
233    use core::fmt::Write;
234    write!(output, "{}", formatter::format(&translated, &WithPlural(arguments, n))).unwrap();
235    output
236}
237
238#[cfg(all(target_family = "unix", feature = "gettext-rs"))]
239fn translate_gettext(
240    string: &str,
241    ctx: &str,
242    domain: &str,
243    n: i32,
244    plural: &str,
245) -> std::string::String {
246    use std::string::String;
247    fn mangle_context(ctx: &str, s: &str) -> String {
248        std::format!("{ctx}\u{4}{s}")
249    }
250    fn demangle_context(r: String) -> String {
251        if let Some(x) = r.split('\u{4}').next_back() {
252            return x.into();
253        }
254        r
255    }
256
257    if plural.is_empty() {
258        if !ctx.is_empty() {
259            demangle_context(gettextrs::dgettext(domain, mangle_context(ctx, string)))
260        } else {
261            gettextrs::dgettext(domain, string)
262        }
263    } else if !ctx.is_empty() {
264        demangle_context(gettextrs::dngettext(
265            domain,
266            mangle_context(ctx, string),
267            mangle_context(ctx, plural),
268            n as u32,
269        ))
270    } else {
271        gettextrs::dngettext(domain, string, plural, n as u32)
272    }
273}
274
275/// Returns the language index and make sure to register a dependency
276fn global_translation_property() -> usize {
277    crate::context::GLOBAL_CONTEXT.with(|ctx| {
278        let Some(ctx) = ctx.get() else { return 0 };
279        ctx.0.as_ref().project_ref().translations_dirty.get()
280    })
281}
282
283pub fn mark_all_translations_dirty() {
284    // _nl_msg_cat_cntr is defined by glibc and the standalone GNU libintl, but not by musl,
285    // which provides its own gettext implementation without that cache counter.
286    #[cfg(all(feature = "gettext-rs", target_family = "unix", not(target_env = "musl")))]
287    {
288        // SAFETY: This trick from https://www.gnu.org/software/gettext/manual/html_node/gettext-grok.html
289        // is merely incrementing a generational counter that will invalidate gettext's internal cache for translations.
290        // If in the worst case it won't invalidate, then old translations are shown.
291        #[allow(unsafe_code)]
292        unsafe {
293            unsafe extern "C" {
294                static mut _nl_msg_cat_cntr: std::ffi::c_int;
295            }
296            _nl_msg_cat_cntr += 1;
297        }
298    }
299
300    crate::context::GLOBAL_CONTEXT.with(|ctx| {
301        let Some(ctx) = ctx.get() else { return };
302        let pinned = ctx.0.as_ref().project_ref();
303        pinned.translations_dirty.mark_dirty();
304
305        // Update the decimal separator
306        #[cfg(all(feature = "gettext-rs", target_family = "unix"))]
307        if let Some(locale) = sys_locale::get_locale() {
308            pinned
309                .locale_decimal_separator
310                .set(i_slint_common::decimal_separator_for_locale(&locale))
311        }
312    })
313}
314
315#[cfg(feature = "gettext-rs")]
316/// Initialize the translation by calling the [`bindtextdomain`](https://man7.org/linux/man-pages/man3/bindtextdomain.3.html) function from gettext
317pub fn gettext_bindtextdomain(_domain: &str, _dirname: std::path::PathBuf) -> std::io::Result<()> {
318    #[cfg(target_family = "unix")]
319    {
320        gettextrs::bindtextdomain(_domain, _dirname)?;
321        static START: std::sync::Once = std::sync::Once::new();
322        START.call_once(|| {
323            gettextrs::setlocale(gettextrs::LocaleCategory::LcAll, "");
324        });
325
326        mark_all_translations_dirty();
327    }
328    Ok(())
329}
330
331/// Translate the strings bundled into the applications. If the desired language is not available, use the default
332///
333/// `strs` - the string which should be translated. The slice contains the string in multiple languages
334/// `arguments` - arguments for the translation
335pub fn translate_from_bundle(
336    strs: &[Option<&str>],
337    arguments: &(impl FormatArgs + ?Sized),
338) -> SharedString {
339    let idx = global_translation_property();
340    let mut output = SharedString::default();
341    let Some(translated) = strs.get(idx).and_then(|x| *x).or_else(|| strs.first().and_then(|x| *x))
342    else {
343        return output;
344    };
345    use core::fmt::Write;
346    write!(output, "{}", formatter::format(translated, arguments)).unwrap();
347    output
348}
349
350/// Translate the strings bundled into the applications with plurals. If the desired language is not available, use the default
351///
352/// `strs` - the string which should be translated
353/// `plural_rules` - the rules to create the plurals
354/// `arguments` - arguments for the translation
355pub fn translate_from_bundle_with_plural(
356    strs: &[Option<&[&str]>],
357    plural_rules: &[Option<fn(i32) -> usize>],
358    arguments: &(impl FormatArgs + ?Sized),
359    n: i32,
360) -> SharedString {
361    let idx = global_translation_property();
362    let mut output = SharedString::default();
363    let en = |n| (n != 1) as usize;
364    let (translations, rule) = match strs.get(idx) {
365        Some(Some(x)) => (x, plural_rules.get(idx).and_then(|x| *x).unwrap_or(en)),
366        _ => match strs.first() {
367            Some(Some(x)) => (x, plural_rules.first().and_then(|x| *x).unwrap_or(en)),
368            _ => return output,
369        },
370    };
371    let Some(translated) = translations.get(rule(n)).or_else(|| translations.first()).cloned()
372    else {
373        return output;
374    };
375    use core::fmt::Write;
376    write!(output, "{}", formatter::format(translated, &WithPlural(arguments, n))).unwrap();
377    output
378}
379
380/// This function is called by the generated code to assign the list of bundled languages
381/// and decimal separators.
382/// Do nothing if the list is already assigned.
383/// It selects also the language based on the system locale as default
384pub fn set_bundled_languages(translations: &[TranslationsBundled]) {
385    crate::context::GLOBAL_CONTEXT.with(|ctx| {
386        let Some(ctx) = ctx.get() else { return };
387
388        if ctx.0.translations_bundle.borrow().is_none() {
389            ctx.0.translations_bundle.replace(Some(translations.to_vec()));
390            #[cfg(feature = "std")]
391            if let Some(idx) = language_index_from_sys_locale(translations) {
392                ctx.0.as_ref().project_ref().translations_dirty.set(idx);
393            }
394        }
395    });
396}
397
398/// attempt to select the right bundled translation based on the current system locale
399#[cfg(feature = "std")]
400fn language_index_from_sys_locale(languages: &[TranslationsBundled]) -> Option<usize> {
401    let locale = sys_locale::get_locale()?;
402    // first, try an exact match
403    let idx = languages.iter().position(|x| *x.language == locale);
404    // else, only match the language part
405    fn base(l: &str) -> &str {
406        l.find(['-', '_', '@']).map_or(l, |i| &l[..i])
407    }
408    idx.or_else(|| {
409        let locale = base(&locale);
410        languages.iter().position(|x| base(x.language) == locale)
411    })
412}
413
414#[i_slint_core_macros::slint_doc]
415/// Select the current translation language when using bundled translations.
416///
417/// This function requires that the application's `.slint` file was compiled with bundled translations..
418/// It must be called after creating the first component.
419///
420/// The language string is the locale, which matches the name of the folder that contains the `LC_MESSAGES` folder.
421/// An empty string or `"en"` will select the default language.
422///
423/// Returns `Ok` if the language was selected; [`SelectBundledTranslationError`] otherwise.
424///
425/// See also the [Translation documentation](slint:translations).
426pub fn select_bundled_translation(language: &str) -> Result<(), SelectBundledTranslationError> {
427    crate::context::GLOBAL_CONTEXT.with(|ctx| {
428        let Some(ctx) = ctx.get() else {
429            return Err(SelectBundledTranslationError::NoTranslationsBundled);
430        };
431        let translations = ctx.0.translations_bundle.borrow();
432        let Some(translations) = &*translations else {
433            return Err(SelectBundledTranslationError::NoTranslationsBundled);
434        };
435        let pinned = ctx.0.as_ref().project_ref();
436        if let Some((idx, translation_bundle)) =
437            translations.iter().enumerate().find(|(_i, x)| x.language == language)
438        {
439            pinned.translations_dirty.as_ref().set(idx);
440            // Update the decimal separator
441            pinned.locale_decimal_separator.as_ref().set(translation_bundle.decimal_separator);
442            Ok(())
443        } else if language.is_empty() || language == "en" {
444            pinned.translations_dirty.as_ref().set(0);
445            pinned.locale_decimal_separator.as_ref().set(i_slint_common::DEFAULT_DECIMAL_SEPARATOR);
446            Ok(())
447        } else {
448            Err(SelectBundledTranslationError::LanguageNotFound {
449                available_languages: translations.iter().map(|x| (*x.language).into()).collect(),
450            })
451        }
452    })
453}
454
455/// Error type returned from the [`select_bundled_translation`] function.
456#[derive(Debug)]
457pub enum SelectBundledTranslationError {
458    /// The language was not found. The list of available languages is included in this error variant.
459    LanguageNotFound { available_languages: crate::SharedVector<SharedString> },
460    /// There are no bundled translations. Either [`select_bundled_translation`] was called before creating a component,
461    /// or the application's `.slint` file was compiled without the bundle translation option.
462    NoTranslationsBundled,
463}
464
465impl core::fmt::Display for SelectBundledTranslationError {
466    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
467        match self {
468            SelectBundledTranslationError::LanguageNotFound { available_languages } => {
469                write!(
470                    f,
471                    "The specified language was not found. Available languages are: {available_languages:?}"
472                )
473            }
474            SelectBundledTranslationError::NoTranslationsBundled => {
475                write!(
476                    f,
477                    "There are no bundled translations. Either select_bundled_translation was called before creating a component, or the application's `.slint` file was compiled without the bundle translation option"
478                )
479            }
480        }
481    }
482}
483
484#[cfg(feature = "std")]
485impl std::error::Error for SelectBundledTranslationError {}
486
487#[cfg(feature = "ffi")]
488mod ffi {
489    #![allow(unsafe_code)]
490    use super::*;
491    use crate::slice::Slice;
492
493    /// return the current decimal-separator for the `Platform.decimal-separator` property
494    #[unsafe(no_mangle)]
495    pub extern "C" fn slint_decimal_separator(out: &mut SharedString) {
496        crate::context::GLOBAL_CONTEXT.with(|ctx| {
497            let separator = if let Some(ctx) = ctx.get() {
498                ctx.0.as_ref().project_ref().locale_decimal_separator.get()
499            } else {
500                i_slint_common::DEFAULT_DECIMAL_SEPARATOR
501            };
502            *out = crate::SharedString::from(separator)
503        })
504    }
505
506    /// Perform the translation and formatting.
507    #[unsafe(no_mangle)]
508    pub extern "C" fn slint_translate(
509        to_translate: &mut SharedString,
510        context: &SharedString,
511        domain: &SharedString,
512        arguments: Slice<SharedString>,
513        n: i32,
514        plural: &SharedString,
515    ) {
516        *to_translate =
517            translate(to_translate.as_str(), context, domain, arguments.as_slice(), n, plural)
518    }
519
520    /// Mark all translated string as dirty to perform re-translation in case the language change
521    #[unsafe(no_mangle)]
522    pub extern "C" fn slint_translations_mark_dirty() {
523        mark_all_translations_dirty();
524    }
525
526    /// Safety: The slice must contain valid null-terminated utf-8 strings
527    #[unsafe(no_mangle)]
528    pub unsafe extern "C" fn slint_translate_from_bundle(
529        strs: Slice<*const core::ffi::c_char>,
530        arguments: Slice<SharedString>,
531        output: &mut SharedString,
532    ) {
533        *output = SharedString::default();
534        let idx = global_translation_property();
535        let Some(translated) = strs
536            .get(idx)
537            .filter(|x| !x.is_null())
538            .or_else(|| strs.first())
539            .map(|x| unsafe { core::ffi::CStr::from_ptr(*x) }.to_str().unwrap())
540        else {
541            return;
542        };
543        use core::fmt::Write;
544        write!(output, "{}", formatter::format(translated, arguments.as_slice())).unwrap();
545    }
546    /// strs is all the strings variant of all languages.
547    /// indices is the array of indices such that for each language, the corresponding indice is one past the last index of the string for that language.
548    /// So to get the string array for that language, one would do `strs[indices[lang-1]..indices[lang]]`
549    /// (where indices[-1] is 0)
550    ///
551    /// Safety; the strs must be pointer to valid null-terminated utf-8 strings
552    #[unsafe(no_mangle)]
553    pub unsafe extern "C" fn slint_translate_from_bundle_with_plural(
554        strs: Slice<*const core::ffi::c_char>,
555        indices: Slice<u32>,
556        plural_rules: Slice<Option<fn(i32) -> usize>>,
557        arguments: Slice<SharedString>,
558        n: i32,
559        output: &mut SharedString,
560    ) {
561        *output = SharedString::default();
562        let idx = global_translation_property();
563        let en = |n| (n != 1) as usize;
564        let begin = *indices.get(idx.wrapping_sub(1)).unwrap_or(&0);
565        let (translations, rule) = match indices.get(idx) {
566            Some(end) if *end != begin => (
567                &strs.as_slice()[begin as usize..*end as usize],
568                plural_rules.get(idx).and_then(|x| *x).unwrap_or(en),
569            ),
570            _ => (
571                &strs.as_slice()[..*indices.first().unwrap_or(&0) as usize],
572                plural_rules.first().and_then(|x| *x).unwrap_or(en),
573            ),
574        };
575        let Some(translated) = translations
576            .get(rule(n))
577            .or_else(|| translations.first())
578            .map(|x| unsafe { core::ffi::CStr::from_ptr(*x) }.to_str().unwrap())
579        else {
580            return;
581        };
582        use core::fmt::Write;
583        write!(output, "{}", formatter::format(translated, &WithPlural(arguments.as_slice(), n)))
584            .unwrap();
585    }
586
587    #[unsafe(no_mangle)]
588    pub extern "C" fn slint_translate_set_bundled_languages(
589        languages: Slice<Slice<'static, u8>>,
590        separators: Slice<u32>,
591    ) {
592        let translations = languages
593            .iter()
594            .zip(separators.as_slice().iter())
595            .map(|(language, separator)| TranslationsBundled {
596                language: core::str::from_utf8(language.as_slice()).unwrap(),
597                decimal_separator: core::char::from_u32(*separator)
598                    .unwrap_or(i_slint_common::DEFAULT_DECIMAL_SEPARATOR),
599            })
600            .collect::<alloc::vec::Vec<_>>();
601        set_bundled_languages(&translations);
602    }
603
604    #[unsafe(no_mangle)]
605    pub extern "C" fn slint_translate_select_bundled_translation(language: Slice<u8>) -> bool {
606        let language = core::str::from_utf8(&language).unwrap();
607        select_bundled_translation(language).is_ok()
608    }
609}