Skip to main content

egui_charts/
localization.rs

1//! Host-supplied UI translations, scoped to an egui context.
2//!
3//! English text from the crate is the lookup key and the fallback. Install a
4//! lookup before drawing the UI; replacing it changes the next frame, including
5//! already-open dialogs. Missing entries return `None`. The host owns language
6//! selection, translation resources and fonts for its chosen languages.
7//!
8//! ```
9//! let ctx = egui::Context::default();
10//! egui_charts::localization::set_translator(&ctx, |english| {
11//!     match english {
12//!         "Cancel" => Some("Abbrechen".to_owned()),
13//!         _ => None,
14//!     }
15//! });
16//! ```
17
18use egui::{Context, Id};
19use std::sync::Arc;
20
21type Lookup = dyn Fn(&str) -> Option<String> + Send + Sync;
22
23#[derive(Clone)]
24struct Translator(Arc<Lookup>);
25
26fn translator_id() -> Id {
27    Id::NULL.with("egui_charts::localization")
28}
29
30/// Install or replace this context's translation lookup and request a repaint.
31///
32/// Keys are the original English labels, including punctuation and case.
33/// Templates passed to [`format_text`] use braces, such as `"Level {0} visible"`;
34/// translations may reorder or repeat those placeholders. Return `None` to
35/// retain the English text. Lookups run outside egui's context locks.
36pub fn set_translator(
37    ctx: &Context,
38    lookup: impl Fn(&str) -> Option<String> + Send + Sync + 'static,
39) {
40    ctx.data_mut(|data| data.insert_temp(translator_id(), Translator(Arc::new(lookup))));
41    ctx.request_repaint();
42}
43
44/// Restore this context's built-in English UI and request a repaint.
45pub fn clear_translator(ctx: &Context) {
46    ctx.data_mut(|data| data.remove::<Translator>(translator_id()));
47    ctx.request_repaint();
48}
49
50/// Resolve a UI label through the host lookup, falling back to its English text.
51///
52/// This also lets hosts use the same lookup in their own controls. Translation
53/// changes presentation only; keep identifiers and stored values in their
54/// original form. Numeric and date formatting use the chart's formatters.
55pub fn text(ctx: &Context, english: impl AsRef<str>) -> String {
56    let english = english.as_ref();
57    lookup(ctx, english).unwrap_or_else(|| english.to_owned())
58}
59
60/// Translate a message template and substitute its named, already-formatted values.
61///
62/// Unknown placeholders remain literal. Values are inserted once, so braces in
63/// a symbol, name or error message cannot become another substitution.
64/// Values may be borrowed or owned strings.
65pub fn format_text(ctx: &Context, english: &str, values: &[(&str, impl AsRef<str>)]) -> String {
66    substitute(lookup(ctx, english).as_deref().unwrap_or(english), values)
67}
68
69/// The host's translation of `english`, or `None` to keep the English text.
70pub(crate) fn lookup(ctx: &Context, english: &str) -> Option<String> {
71    let translator = ctx.data(|data| data.get_temp::<Translator>(translator_id()));
72    translator.and_then(|lookup| (lookup.0)(english))
73}
74
75/// `template` with each `{name}` that names one of `values` replaced once; other braces stay
76/// literal.
77pub(crate) fn substitute(template: &str, values: &[(&str, impl AsRef<str>)]) -> String {
78    // Room for every value once: one allocation unless a translation repeats a placeholder.
79    let mut output = String::with_capacity(
80        template.len()
81            + values
82                .iter()
83                .map(|(_, value)| value.as_ref().len())
84                .sum::<usize>(),
85    );
86    let mut rest = template;
87    while let Some(open) = rest.find('{') {
88        output.push_str(&rest[..open]);
89        rest = &rest[open..];
90        let Some(close) = rest.find('}') else {
91            break;
92        };
93        let key = &rest[1..close];
94        if let Some((_, value)) = values.iter().find(|(name, _)| *name == key) {
95            output.push_str(value.as_ref());
96        } else {
97            output.push_str(&rest[..=close]);
98        }
99        rest = &rest[close + 1..];
100    }
101    output.push_str(rest);
102    output
103}
104
105/// Installs a lookup that marks keys while preserving placeholders. Text with digits
106/// outside braces already holds a value and stays unchanged, exposing untranslated phrases.
107#[cfg(test)]
108pub(crate) fn mark_keys(ctx: &Context) {
109    set_translator(ctx, |key| {
110        if !key.chars().any(|c| c.is_ascii_alphabetic()) {
111            return None;
112        }
113        let mut marked = String::from("~");
114        let mut rest = key;
115        while let Some(open) = rest.find('{') {
116            if rest[..open].chars().any(|c| c.is_ascii_digit()) {
117                return None;
118            }
119            rest = &rest[open..];
120            let Some(close) = rest.find('}') else {
121                break;
122            };
123            marked.push_str(&rest[..=close]);
124            marked.push('~');
125            rest = &rest[close + 1..];
126        }
127        if rest.chars().any(|c| c.is_ascii_digit()) {
128            None
129        } else {
130            Some(marked)
131        }
132    });
133}
134
135#[cfg(test)]
136mod tests {
137    use super::*;
138
139    #[test]
140    fn context_lookup_falls_back_switches_and_substitutes_values_once() {
141        let ctx = Context::default();
142        let other = Context::default();
143        assert_eq!(text(&ctx, "Cancel"), "Cancel");
144        let read_context = ctx.clone();
145        set_translator(&ctx, move |english| {
146            // A host lookup may read egui state without reentering a context lock.
147            let _ = read_context.global_style();
148            match english {
149                "Cancel" => Some("Abbrechen".into()),
150                "Copy price {price_str}" => Some("{price_str} kopieren ({price_str})".into()),
151                "Name {name}, price {price}" => Some("{price}: {name}; {missing}; {".into()),
152                "Hidden" => Some(String::new()),
153                _ => None,
154            }
155        });
156        assert_eq!(text(&ctx, "Cancel"), "Abbrechen");
157        assert_eq!(text(&other, "Cancel"), "Cancel");
158        assert_eq!(text(&ctx, "Unknown"), "Unknown");
159        assert_eq!(text(&ctx, "Hidden"), "");
160        for (english, values, expected) in [
161            (
162                "Copy price {price_str}",
163                vec![("price_str", "12.50".to_owned())],
164                "12.50 kopieren (12.50)",
165            ),
166            (
167                "Name {name}, price {price}",
168                vec![
169                    ("name", "{price}".to_owned()),
170                    ("price", "12.50".to_owned()),
171                ],
172                "12.50: {price}; {missing}; {",
173            ),
174            (
175                "Missing {name}",
176                vec![("name", "TEST".to_owned())],
177                "Missing TEST",
178            ),
179        ] {
180            assert_eq!(format_text(&ctx, english, &values), expected);
181        }
182        set_translator(&ctx, |english| {
183            (english == "Cancel").then(|| "Annuler".into())
184        });
185        assert_eq!(text(&ctx, "Cancel"), "Annuler");
186        clear_translator(&ctx);
187        assert_eq!(text(&ctx, "Cancel"), "Cancel");
188    }
189}