Skip to main content

teksilo_widgets/
language_switcher.rs

1// SPDX-License-Identifier: MPL-2.0
2// SPDX-FileCopyrightText: 2026 FernTech
3
4//! LanguageSwitcher — a drop-in UI-language picker for settings screens.
5//!
6//! A thin [`ComboBox`] preset that lists the application's supported
7//! locales and switches the active locale on selection. Each entry is
8//! shown as its **endonym** — the language's own name — followed by the
9//! BCP-47 tag, e.g. `français (fr-FR)`, `Deutsch (de-DE)`,
10//! `العربية (ar-SA)`. Showing endonyms (not "French", "German", "Arabic")
11//! means a speaker of each language can always find their own in the list.
12//!
13//! Zero-config: drop it into a settings panel and it
14//!
15//! - self-populates from the installed `I18nManager`
16//!   (`teksilo_i18n::current_supported_locales()`),
17//! - shows the active locale as the current selection
18//!   (`teksilo_i18n::current_locale()`),
19//! - switches the app locale on selection via `EventContext::set_locale`,
20//!   which the window manager fans out to every window (re-translating
21//!   text and flipping layout direction for RTL locales like Arabic),
22//! - and keeps its selection in sync if the locale is changed elsewhere.
23//!
24//! ```ignore
25//! // In a settings panel's build():
26//! VStack::new()
27//!     .child(TextWidget::new(tr!(ui_language())).style(TextStyleRole::BodyBold))
28//!     .child(LanguageSwitcher::new())
29//! ```
30//!
31//! Endonyms come from ICU4X CLDR data via
32//! [`teksilo_i18n::language_endonym`]; an unknown tag falls back to the
33//! raw BCP-47 tag. When no `I18nManager` is configured the switcher
34//! renders an empty, placeholder ComboBox.
35
36use teksilo_canvas::{Rect, SizeProposal};
37use teksilo_core::accessibility::AccessNodeBuilder;
38use teksilo_core::build_context::BuildContext;
39use teksilo_core::signal::Signal;
40use teksilo_core::widget::{LayoutContext, LayoutResponse, Widget, WidgetPlacement};
41use teksilo_core::widget_id::WidgetId;
42use teksilo_i18n::{
43    LanguageIdentifier, LocalizedString, current_locale, current_supported_locales,
44    language_endonym, lit,
45};
46
47use crate::combo_box::{ComboBox, ComboBoxVariant};
48
49/// One row in the switcher: the locale's BCP-47 `tag` (the value committed
50/// to `set_locale`) and the user-facing `display` string
51/// (`"<endonym> (<tag>)"`).
52#[derive(Clone, PartialEq)]
53struct LocaleChoice {
54    tag: String,
55    display: String,
56}
57
58/// A UI-language picker built on [`ComboBox`]. See the module docs.
59pub struct LanguageSwitcher {
60    /// Forwarded to the inner [`ComboBox`]. Defaults to `Outlined`.
61    variant: ComboBoxVariant,
62    /// Accessible / control label. Defaults to `lit!("Language")`; pass a
63    /// `tr!(...)` to localize it.
64    label: Option<LocalizedString>,
65    /// Explicit locale list. When `None` (the default), the switcher reads
66    /// the supported locales from the active `I18nManager`.
67    locales_override: Option<Vec<LanguageIdentifier>>,
68    /// The inner ComboBox's value signal. Owned here so the locale-sync
69    /// effect can keep it aligned with the active locale.
70    selected: Signal<Option<LocaleChoice>>,
71    /// Optional plain tooltip text, forwarded to the inner [`ComboBox`].
72    /// Mutually exclusive with the rich / composite variants.
73    tooltip_text: Option<LocalizedString>,
74    /// Optional rich tooltip source, forwarded to the inner [`ComboBox`].
75    rich_tooltip_source: Option<crate::tooltip::RichTooltipSource>,
76    /// Optional composite tooltip body, forwarded to the inner [`ComboBox`].
77    composite_tooltip_content: Option<Box<dyn Widget>>,
78    root_child_id: Option<WidgetId>,
79}
80
81impl Default for LanguageSwitcher {
82    fn default() -> Self {
83        Self::new()
84    }
85}
86
87impl std::fmt::Debug for LanguageSwitcher {
88    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
89        f.debug_struct("LanguageSwitcher")
90            .field("variant", &self.variant)
91            .field("locales_override", &self.locales_override)
92            .finish()
93    }
94}
95
96impl LanguageSwitcher {
97    /// Create a switcher that auto-discovers the supported locales from
98    /// the active `I18nManager`.
99    pub fn new() -> Self {
100        Self {
101            variant: ComboBoxVariant::default(),
102            label: None,
103            locales_override: None,
104            selected: Signal::new(None),
105            tooltip_text: None,
106            rich_tooltip_source: None,
107            composite_tooltip_content: None,
108            root_child_id: None,
109        }
110    }
111
112    /// Pick the inner ComboBox's design-language variant.
113    pub fn variant(mut self, variant: ComboBoxVariant) -> Self {
114        self.variant = variant;
115        self
116    }
117
118    /// Set the accessible / control label (defaults to `"Language"`).
119    /// Pass a `tr!(...)` to localize it.
120    pub fn label(mut self, label: impl Into<LocalizedString>) -> Self {
121        self.label = Some(label.into());
122        self
123    }
124
125    /// Override the locale list instead of auto-discovering it from the
126    /// active `I18nManager`. Useful in previews / tests, or to restrict
127    /// the offered set.
128    pub fn locales(mut self, locales: Vec<LanguageIdentifier>) -> Self {
129        self.locales_override = Some(locales);
130        self
131    }
132
133    /// Attach a plain tooltip, forwarded to the inner [`ComboBox`].
134    /// Mutually exclusive with the rich / composite variants — last
135    /// call wins.
136    pub fn tooltip(mut self, text: impl Into<LocalizedString>) -> Self {
137        self.tooltip_text = Some(text.into());
138        self.rich_tooltip_source = None;
139        self.composite_tooltip_content = None;
140        self
141    }
142
143    /// Attach a rich tooltip resolved from the app-wide registry,
144    /// forwarded to the inner [`ComboBox`]. Overrides any previously
145    /// set tooltip.
146    pub fn rich_tooltip(mut self, key: impl Into<String>) -> Self {
147        self.rich_tooltip_source = Some(crate::tooltip::RichTooltipSource::Key(key.into()));
148        self.tooltip_text = None;
149        self.composite_tooltip_content = None;
150        self
151    }
152
153    /// Attach a rich tooltip driven by inline
154    /// [`TooltipContent`](crate::tooltip::TooltipContent), forwarded to
155    /// the inner [`ComboBox`]. Overrides any previously set tooltip.
156    pub fn rich_tooltip_content(mut self, content: crate::tooltip::TooltipContent) -> Self {
157        self.rich_tooltip_source = Some(crate::tooltip::RichTooltipSource::Content(content));
158        self.tooltip_text = None;
159        self.composite_tooltip_content = None;
160        self
161    }
162
163    /// Attach a composite tooltip hosting an arbitrary widget tree,
164    /// forwarded to the inner [`ComboBox`]. Overrides any previously
165    /// set tooltip.
166    pub fn composite_tooltip(mut self, content: impl Widget + 'static) -> Self {
167        self.composite_tooltip_content = Some(Box::new(content));
168        self.tooltip_text = None;
169        self.rich_tooltip_source = None;
170        self
171    }
172
173    /// Build the `"<endonym> (<tag>)"` choices for a locale list.
174    fn choices_for(locales: &[LanguageIdentifier]) -> Vec<LocaleChoice> {
175        locales
176            .iter()
177            .map(|l| {
178                let tag = l.to_string();
179                let endonym = language_endonym(l).unwrap_or_else(|| tag.clone());
180                LocaleChoice {
181                    display: format!("{endonym} ({tag})"),
182                    tag,
183                }
184            })
185            .collect()
186    }
187}
188
189impl Widget for LanguageSwitcher {
190    fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
191        let locales = self
192            .locales_override
193            .clone()
194            .or_else(current_supported_locales)
195            .unwrap_or_default();
196        let choices = Self::choices_for(&locales);
197
198        // Seed the selection from the active locale so the closed combo
199        // shows the current language.
200        let active_tag = current_locale().map(|s| s.get().to_string());
201        let initial = active_tag
202            .as_ref()
203            .and_then(|t| choices.iter().find(|c| &c.tag == t).cloned());
204        self.selected.set(initial);
205
206        let label = self.label.clone().unwrap_or_else(|| lit!("Language"));
207
208        let mut combo = ComboBox::from_items(
209            choices.clone(),
210            self.selected.clone(),
211            |c: &LocaleChoice| LocalizedString::literal(c.display.clone()),
212        )
213        .variant(self.variant)
214        .label(label)
215        .placeholder(lit!("Language"))
216        // The reason this widget needs `ComboBox::on_select` (not a plain
217        // signal observer): `set_locale` lives on `EventContext`, so the
218        // full window-manager fan-out (redraw-all + RTL layout direction)
219        // only happens on this context-bearing path.
220        .on_select(|c: &LocaleChoice, ctx| ctx.set_locale(c.tag.clone()));
221
222        // Forward any configured tooltip onto the inner ComboBox. The
223        // three setters are mutually exclusive, so exactly one branch
224        // runs (last-call-wins, mirroring the ComboBox surface).
225        if let Some(content) = self.composite_tooltip_content.take() {
226            combo = combo.composite_tooltip_boxed(content);
227        } else if let Some(source) = self.rich_tooltip_source.clone() {
228            combo = match source {
229                crate::tooltip::RichTooltipSource::Key(k) => combo.rich_tooltip(k),
230                crate::tooltip::RichTooltipSource::Content(c) => combo.rich_tooltip_content(c),
231            };
232        } else if let Some(text) = self.tooltip_text.clone() {
233            combo = combo.tooltip(text);
234        }
235
236        let combo_id = ctx.add(combo);
237        self.root_child_id = Some(combo_id);
238
239        // Keep the selection aligned if the locale is changed from
240        // elsewhere (another switcher, a menu, the inspector). Endonym
241        // strings are language-stable, so the choice list itself never
242        // needs rebuilding on a locale change — only the selection.
243        if let Some(locale_sig) = current_locale() {
244            let selected = self.selected.clone();
245            let choices = choices.clone();
246            ctx.effect(&locale_sig, move |loc| {
247                let tag = loc.to_string();
248                let next = choices.iter().find(|c| c.tag == tag).cloned();
249                if selected.get() != next {
250                    selected.set(next);
251                }
252            });
253        }
254
255        vec![combo_id]
256    }
257
258    fn layout_response(&self, proposal: SizeProposal, ctx: &LayoutContext) -> LayoutResponse {
259        self.root_child_id
260            .and_then(|id| ctx.child_size(id, proposal))
261            .unwrap_or_else(|| proposal.resolve(0.0, 0.0))
262            .into()
263    }
264
265    fn place_children(
266        &self,
267        bounds: Rect,
268        _proposal: SizeProposal,
269        children: &mut [WidgetPlacement],
270        _ctx: &LayoutContext,
271    ) {
272        for child in children.iter_mut() {
273            child.origin = bounds.origin();
274            child.size = bounds.size();
275        }
276    }
277
278    fn children(&self) -> Vec<WidgetId> {
279        self.root_child_id.into_iter().collect()
280    }
281
282    fn accessibility(&self, _builder: &mut AccessNodeBuilder) {
283        // The inner ComboBox carries the control role + label.
284    }
285}
286
287#[cfg(test)]
288mod tests {
289    use super::*;
290    use teksilo_core::widget_tree::WidgetTree;
291
292    fn light_tree() -> WidgetTree {
293        WidgetTree::new().with_theme(teksilo_core::presets::intui::light())
294    }
295
296    fn langs(tags: &[&str]) -> Vec<LanguageIdentifier> {
297        tags.iter().map(|t| t.parse().unwrap()).collect()
298    }
299
300    #[test]
301    fn choices_show_endonym_and_tag() {
302        let choices = LanguageSwitcher::choices_for(&langs(&["fr-FR", "de-DE"]));
303        assert_eq!(choices[0].tag, "fr-FR");
304        assert_eq!(choices[0].display, "français (fr-FR)");
305        assert_eq!(choices[1].display, "Deutsch (de-DE)");
306    }
307
308    #[test]
309    fn unknown_tag_falls_back_to_raw_tag() {
310        // A private-use tag has no CLDR endonym.
311        let choices = LanguageSwitcher::choices_for(&langs(&["qaa"]));
312        assert_eq!(choices[0].display, "qaa (qaa)");
313    }
314
315    #[test]
316    fn builds_and_lays_out_with_explicit_locales() {
317        let mut tree = light_tree();
318        let id = tree.add(LanguageSwitcher::new().locales(langs(&["en-US", "fr-FR", "ar-SA"])));
319        tree.layout(SizeProposal::exact(300.0, 50.0));
320        assert!(tree.bounds(id).width > 0.0);
321    }
322
323    #[test]
324    fn empty_when_no_locales() {
325        // No manager + no override → empty list, still builds without panic.
326        let mut tree = light_tree();
327        let id = tree.add(LanguageSwitcher::new());
328        tree.layout(SizeProposal::exact(300.0, 50.0));
329        assert!(tree.bounds(id).width >= 0.0);
330    }
331
332    #[test]
333    fn tooltip_appears_on_hover() {
334        let mut tree = light_tree();
335        let id = tree.add(
336            LanguageSwitcher::new()
337                .locales(langs(&["en-US", "fr-FR"]))
338                .tooltip(LocalizedString::literal("Tip")),
339        );
340        tree.layout(SizeProposal::exact(300.0, 200.0));
341        tree.pointer_move(tree.bounds(id).center());
342        tree.advance_time(std::time::Duration::from_secs(1));
343        assert_eq!(
344            tree.active_overlays().len(),
345            1,
346            "tooltip should appear on hover"
347        );
348        assert!(tree.find_by_label("Tip").is_some());
349    }
350}