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