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