1use std::rc::Rc;
37
38use teksilo_canvas::{Rect, SizeProposal};
39use teksilo_core::accessibility::AccessNodeBuilder;
40use teksilo_core::build_context::BuildContext;
41use teksilo_core::signal::Signal;
42use teksilo_core::styles::{Theme, ThemeId};
43use teksilo_core::widget::{LayoutContext, LayoutResponse, Widget, WidgetPlacement};
44use teksilo_core::widget_id::WidgetId;
45use teksilo_i18n::{LocalizedString, tr_widget};
46
47use crate::combo_box::{ComboBox, ComboBoxVariant};
48
49#[derive(Clone, PartialEq)]
53struct ThemeChoice {
54 id: ThemeId,
55 display: String,
56}
57
58#[derive(Clone)]
62enum ThemeAction {
63 Set(Box<Theme>),
64 FollowSystem,
65}
66
67pub struct ThemeSwitcher {
69 variant: ComboBoxVariant,
71 label: Option<LocalizedString>,
73 themes_override: Option<Vec<(LocalizedString, Theme)>>,
76 include_system: bool,
78 selected: Signal<Option<ThemeChoice>>,
81 tooltip_text: Option<LocalizedString>,
84 rich_tooltip_source: Option<crate::tooltip::RichTooltipSource>,
86 composite_tooltip_content: Option<Box<dyn Widget>>,
88 root_child_id: Option<WidgetId>,
89}
90
91impl Default for ThemeSwitcher {
92 fn default() -> Self {
93 Self::new()
94 }
95}
96
97impl std::fmt::Debug for ThemeSwitcher {
98 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
99 f.debug_struct("ThemeSwitcher")
100 .field("variant", &self.variant)
101 .field("include_system", &self.include_system)
102 .finish()
103 }
104}
105
106fn default_label(id: &str) -> Option<LocalizedString> {
110 match id {
111 "intui.light" => Some(tr_widget!(theme_switcher_light())),
112 "intui.dark" => Some(tr_widget!(theme_switcher_dark())),
113 "system" => Some(tr_widget!(theme_switcher_system())),
114 _ => None,
115 }
116}
117
118impl ThemeSwitcher {
119 pub fn new() -> Self {
122 Self {
123 variant: ComboBoxVariant::default(),
124 label: None,
125 themes_override: None,
126 include_system: true,
127 selected: Signal::new(None),
128 tooltip_text: None,
129 rich_tooltip_source: None,
130 composite_tooltip_content: None,
131 root_child_id: None,
132 }
133 }
134
135 pub fn variant(mut self, variant: ComboBoxVariant) -> Self {
137 self.variant = variant;
138 self
139 }
140
141 pub fn label(mut self, label: impl Into<LocalizedString>) -> Self {
143 self.label = Some(label.into());
144 self
145 }
146
147 pub fn themes(
152 mut self,
153 themes: impl IntoIterator<Item = (impl Into<LocalizedString>, Theme)>,
154 ) -> Self {
155 self.themes_override = Some(
156 themes
157 .into_iter()
158 .map(|(label, theme)| (label.into(), theme))
159 .collect(),
160 );
161 self
162 }
163
164 pub fn system(mut self, include: bool) -> Self {
166 self.include_system = include;
167 self
168 }
169
170 pub fn tooltip(mut self, text: impl Into<LocalizedString>) -> Self {
174 self.tooltip_text = Some(text.into());
175 self.rich_tooltip_source = None;
176 self.composite_tooltip_content = None;
177 self
178 }
179
180 pub fn rich_tooltip(mut self, key: impl Into<String>) -> Self {
184 self.rich_tooltip_source = Some(crate::tooltip::RichTooltipSource::Key(key.into()));
185 self.tooltip_text = None;
186 self.composite_tooltip_content = None;
187 self
188 }
189
190 pub fn rich_tooltip_content(mut self, content: crate::tooltip::TooltipContent) -> Self {
194 self.rich_tooltip_source = Some(crate::tooltip::RichTooltipSource::Content(content));
195 self.tooltip_text = None;
196 self.composite_tooltip_content = None;
197 self
198 }
199
200 pub fn composite_tooltip(mut self, content: impl Widget + 'static) -> Self {
204 self.composite_tooltip_content = Some(Box::new(content));
205 self.tooltip_text = None;
206 self.rich_tooltip_source = None;
207 self
208 }
209
210 fn entries(&self) -> Vec<(String, ThemeId, ThemeAction)> {
212 let mut out: Vec<(String, ThemeId, ThemeAction)> = Vec::new();
213 match &self.themes_override {
214 Some(custom) => {
215 for (label, theme) in custom {
216 out.push((
217 label.resolve_now(),
218 theme.id.clone(),
219 ThemeAction::Set(Box::new(theme.clone())),
220 ));
221 }
222 }
223 None => {
224 let light = teksilo_core::presets::intui::light();
225 let dark = teksilo_core::presets::intui::dark();
226 out.push((
231 tr_widget!(theme_switcher_light()).resolve_now(),
232 light.id.clone(),
233 ThemeAction::Set(Box::new(light)),
234 ));
235 out.push((
236 tr_widget!(theme_switcher_dark()).resolve_now(),
237 dark.id.clone(),
238 ThemeAction::Set(Box::new(dark)),
239 ));
240 }
241 }
242 if self.include_system {
243 out.push((
244 tr_widget!(theme_switcher_system()).resolve_now(),
245 ThemeId::new("system"),
246 ThemeAction::FollowSystem,
247 ));
248 }
249 out
250 }
251}
252
253impl Widget for ThemeSwitcher {
254 fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
255 let entries = self.entries();
256 let choices: Vec<ThemeChoice> = entries
257 .iter()
258 .map(|(display, id, _)| ThemeChoice {
259 id: id.clone(),
260 display: display.clone(),
261 })
262 .collect();
263 let actions: Rc<Vec<(ThemeId, ThemeAction)>> = Rc::new(
265 entries
266 .into_iter()
267 .map(|(_, id, action)| (id, action))
268 .collect(),
269 );
270
271 let current_id = ctx.theme().id.clone();
274 let initial = choices.iter().find(|c| c.id == current_id).cloned();
275 self.selected.set(initial);
276
277 let label = self
281 .label
282 .clone()
283 .unwrap_or_else(|| tr_widget!(theme_switcher_label()));
284
285 let on_select_actions = actions.clone();
286 let mut combo =
287 ComboBox::from_items(choices.clone(), self.selected.clone(), |c: &ThemeChoice| {
288 default_label(c.id.as_str())
292 .unwrap_or_else(|| LocalizedString::literal(c.display.clone()))
293 })
294 .variant(self.variant)
295 .label(label)
296 .on_select(move |c: &ThemeChoice, ctx| {
300 if let Some((_, action)) = on_select_actions.iter().find(|(id, _)| *id == c.id) {
301 match action {
302 ThemeAction::Set(theme) => ctx.set_theme((**theme).clone()),
303 ThemeAction::FollowSystem => ctx.follow_system_theme(),
304 }
305 }
306 });
307
308 if let Some(content) = self.composite_tooltip_content.take() {
312 combo = combo.composite_tooltip_boxed(content);
313 } else if let Some(source) = self.rich_tooltip_source.clone() {
314 combo = match source {
315 crate::tooltip::RichTooltipSource::Key(k) => combo.rich_tooltip(k),
316 crate::tooltip::RichTooltipSource::Content(c) => combo.rich_tooltip_content(c),
317 };
318 } else if let Some(text) = self.tooltip_text.clone() {
319 combo = combo.tooltip(text);
320 }
321
322 let combo_id = ctx.add(combo);
323 self.root_child_id = Some(combo_id);
324
325 {
328 let selected = self.selected.clone();
329 let choices = choices.clone();
330 ctx.effect(&ctx.theme_signal(), move |theme| {
331 let next = choices.iter().find(|c| c.id == theme.id).cloned();
332 if selected.get() != next {
333 selected.set(next);
334 }
335 });
336 }
337
338 vec![combo_id]
339 }
340
341 fn layout_response(&self, proposal: SizeProposal, ctx: &LayoutContext) -> LayoutResponse {
342 self.root_child_id
343 .and_then(|id| ctx.child_size(id, proposal))
344 .unwrap_or_else(|| proposal.resolve(0.0, 0.0))
345 .into()
346 }
347
348 fn place_children(
349 &self,
350 bounds: Rect,
351 _proposal: SizeProposal,
352 children: &mut [WidgetPlacement],
353 _ctx: &LayoutContext,
354 ) {
355 for child in children.iter_mut() {
356 child.origin = bounds.origin();
357 child.size = bounds.size();
358 }
359 }
360
361 fn children(&self) -> Vec<WidgetId> {
362 self.root_child_id.into_iter().collect()
363 }
364
365 fn accessibility(&self, _builder: &mut AccessNodeBuilder) {
366 }
368}
369
370#[cfg(test)]
371mod tests {
372 use super::*;
373 use teksilo_core::widget_tree::WidgetTree;
374
375 fn light_tree() -> WidgetTree {
376 WidgetTree::new().with_theme(teksilo_core::presets::intui::light())
377 }
378
379 #[test]
380 fn default_entries_are_light_dark_system() {
381 let sw = ThemeSwitcher::new();
382 let entries = sw.entries();
383 let ids: Vec<&str> = entries.iter().map(|(_, id, _)| id.as_str()).collect();
384 assert_eq!(ids, vec!["intui.light", "intui.dark", "system"]);
385 let labels: Vec<&str> = entries.iter().map(|(d, _, _)| d.as_str()).collect();
387 assert_eq!(labels, vec!["Light", "Dark", "System"]);
388 }
389
390 #[test]
391 fn system_can_be_disabled() {
392 let entries = ThemeSwitcher::new().system(false).entries();
393 let ids: Vec<&str> = entries.iter().map(|(_, id, _)| id.as_str()).collect();
394 assert_eq!(ids, vec!["intui.light", "intui.dark"]);
395 }
396
397 #[test]
398 fn builds_and_lays_out() {
399 let mut tree = light_tree();
400 let id = tree.add(ThemeSwitcher::new());
401 tree.layout(SizeProposal::exact(240.0, 40.0));
402 assert!(tree.bounds(id).width > 0.0);
403 }
404
405 #[test]
406 fn tooltip_forwards_to_inner_combo() {
407 let mut tree = light_tree();
410 let id =
411 tree.add(ThemeSwitcher::new().tooltip(LocalizedString::literal("Application theme")));
412 tree.layout(SizeProposal::exact(240.0, 40.0));
413
414 tree.pointer_move(tree.bounds(id).center());
415 tree.advance_time(std::time::Duration::from_secs(1));
416 assert_eq!(
417 tree.active_overlays().len(),
418 1,
419 "ThemeSwitcher tooltip should appear on hover"
420 );
421 assert!(
422 tree.find_by_label("Application theme").is_some(),
423 "the forwarded tooltip content should be present"
424 );
425 }
426
427 fn inner_combo(tree: &WidgetTree, id: WidgetId) -> WidgetId {
429 tree.children(id)
430 .first()
431 .copied()
432 .expect("ThemeSwitcher should wrap one ComboBox child")
433 }
434
435 #[test]
436 fn selecting_dark_row_queues_theme_change() {
437 let mut tree = light_tree();
438 let id = tree.add(ThemeSwitcher::new());
439 tree.layout(SizeProposal::exact(240.0, 240.0));
440
441 let combo = inner_combo(&tree, id);
442 tree.focus(combo);
443 tree.press_key(
446 teksilo_core::event::Key::ArrowDown,
447 teksilo_core::event::Modifiers::NONE,
448 );
449 let pending = tree.take_pending_theme_request();
450 assert!(
451 pending.is_some(),
452 "selecting Dark must queue a theme switch"
453 );
454 assert_eq!(pending.unwrap().id.as_str(), "intui.dark");
455 }
456
457 #[test]
458 fn tooltip_appears_after_hover_delay() {
459 use std::cell::RefCell;
460 use std::time::Duration;
461 use teksilo_canvas::MockTextBackend;
462
463 let mut tree = WidgetTree::new()
464 .with_theme(teksilo_core::presets::intui::light())
465 .with_text_backend(Rc::new(RefCell::new(MockTextBackend::new())));
466 let id = tree.add(ThemeSwitcher::new().tooltip(teksilo_i18n::lit!("Pick the app theme")));
467 tree.layout(SizeProposal::exact(240.0, 40.0));
468
469 assert!(tree.active_overlays().is_empty());
470 tree.pointer_move(tree.bounds(id).center());
471 assert!(tree.active_overlays().is_empty());
473 tree.advance_time(Duration::from_secs(2));
474 assert_eq!(
475 tree.active_overlays().len(),
476 1,
477 "ThemeSwitcher tooltip should appear after the hover delay"
478 );
479 }
480
481 #[test]
482 fn selecting_system_row_requests_follow_os() {
483 let mut tree = light_tree();
484 let id = tree.add(ThemeSwitcher::new());
485 tree.layout(SizeProposal::exact(240.0, 240.0));
486
487 let combo = inner_combo(&tree, id);
488 tree.focus(combo);
489 tree.press_key(
491 teksilo_core::event::Key::ArrowDown,
492 teksilo_core::event::Modifiers::NONE,
493 );
494 let _ = tree.take_pending_theme_request(); tree.press_key(
496 teksilo_core::event::Key::ArrowDown,
497 teksilo_core::event::Modifiers::NONE,
498 );
499 assert!(
500 tree.take_pending_follow_system_request(),
501 "selecting System must request follow-OS mode"
502 );
503 }
504}