teksilo_widgets/
language_switcher.rs1use 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#[derive(Clone, PartialEq)]
53struct LocaleChoice {
54 tag: String,
55 display: String,
56}
57
58pub struct LanguageSwitcher {
60 variant: ComboBoxVariant,
62 label: Option<LocalizedString>,
65 locales_override: Option<Vec<LanguageIdentifier>>,
68 selected: Signal<Option<LocaleChoice>>,
71 tooltip_text: Option<LocalizedString>,
74 rich_tooltip_source: Option<crate::tooltip::RichTooltipSource>,
76 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 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 pub fn variant(mut self, variant: ComboBoxVariant) -> Self {
114 self.variant = variant;
115 self
116 }
117
118 pub fn label(mut self, label: impl Into<LocalizedString>) -> Self {
121 self.label = Some(label.into());
122 self
123 }
124
125 pub fn locales(mut self, locales: Vec<LanguageIdentifier>) -> Self {
129 self.locales_override = Some(locales);
130 self
131 }
132
133 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 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 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 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 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 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 .on_select(|c: &LocaleChoice, ctx| ctx.set_locale(c.tag.clone()));
221
222 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 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 }
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 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 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}