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