Skip to main content

teksilo_widgets/
theme_switcher.rs

1// SPDX-License-Identifier: MPL-2.0
2// SPDX-FileCopyrightText: 2026 FernTech
3
4//! ThemeSwitcher — a drop-in app-theme picker for settings screens & toolbars.
5//!
6//! A thin [`ComboBox`] preset that switches the application theme. By default
7//! it offers three entries — **Light**, **Dark**, and **System** — where
8//! *System* follows the native OS theme live: it adopts the OS's actual colours
9//! (GNOME / KDE / Cinnamon on Linux) and tracks OS light/dark changes at
10//! runtime, falling back to the built-in light/dark presets on platforms
11//! without OS-colour support.
12//!
13//! Zero-config: drop `ThemeSwitcher::new()` into a settings panel or toolbar and
14//! it
15//!
16//! - shows the active theme as the current selection (matched by the theme's
17//!   stable [`ThemeId`]),
18//! - switches the app theme on selection via `EventContext::set_theme` (fixed
19//!   themes) or `EventContext::follow_system_theme` (System),
20//! - and stays in sync if the theme changes elsewhere (a menu, the inspector,
21//!   or an OS light/dark toggle).
22//!
23//! ```ignore
24//! // In a settings panel or toolbar:
25//! Toolbar::new().child(HStack::new().child(Spacer::new()).child(ThemeSwitcher::new()))
26//! ```
27//!
28//! Labels are **translated** via the framework Fluent bundle (`tr_widget!`),
29//! with an English literal fallback so a host app that hasn't installed an
30//! `I18nManager` still reads "Light / Dark / System" rather than raw keys.
31//!
32//! Custom themes: `.themes([(label, theme), …])` replaces Light/Dark with an
33//! app-supplied set (e.g. the `teksilo-theme-{fluent,macos,material3}` presets);
34//! `.system(false)` drops the System entry.
35
36use 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/// One row in the switcher: the theme's stable [`ThemeId`] (used to match the
50/// active theme and to look up the action) and the user-facing `display`
51/// string. `ThemeId` `"system"` denotes the follow-OS entry.
52#[derive(Clone, PartialEq)]
53struct ThemeChoice {
54    id: ThemeId,
55    display: String,
56}
57
58/// What selecting an entry does: pin a fixed theme, or follow the OS.
59/// The fixed theme is boxed because `Theme` is large relative to the
60/// zero-size `FollowSystem` variant.
61#[derive(Clone)]
62enum ThemeAction {
63    Set(Box<Theme>),
64    FollowSystem,
65}
66
67/// A drop-in app-theme picker built on [`ComboBox`]. See the module docs.
68pub struct ThemeSwitcher {
69    /// Forwarded to the inner [`ComboBox`]. Defaults to `Outlined`.
70    variant: ComboBoxVariant,
71    /// Accessible / control label. Defaults to the translated "Theme".
72    label: Option<LocalizedString>,
73    /// Explicit fixed-theme list `(label, theme)`. When `None` (the default),
74    /// the switcher offers Light + Dark.
75    themes_override: Option<Vec<(LocalizedString, Theme)>>,
76    /// Whether to append a "System" (follow-OS) entry. Default `true`.
77    include_system: bool,
78    /// The inner ComboBox's value signal. Owned here so the theme-sync effect
79    /// can keep it aligned with the active theme.
80    selected: Signal<Option<ThemeChoice>>,
81    /// Optional plain tooltip text, forwarded to the inner [`ComboBox`].
82    /// Mutually exclusive with the rich / composite variants.
83    tooltip_text: Option<LocalizedString>,
84    /// Optional rich tooltip source, forwarded to the inner [`ComboBox`].
85    rich_tooltip_source: Option<crate::tooltip::RichTooltipSource>,
86    /// Optional composite tooltip body, forwarded to the inner [`ComboBox`].
87    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
106/// The translated label for a default entry, by theme id. Returns a reactive
107/// [`LocalizedString`] (from `tr_widget!`) that re-resolves on locale change;
108/// without an `I18nManager` the macro itself falls back to the English literal.
109fn 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    /// Create a switcher offering Light / Dark / System (the System entry
120    /// follows the OS theme live).
121    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    /// Pick the inner ComboBox's design-language variant.
136    pub fn variant(mut self, variant: ComboBoxVariant) -> Self {
137        self.variant = variant;
138        self
139    }
140
141    /// Set the accessible / control label (defaults to the translated "Theme").
142    pub fn label(mut self, label: impl Into<LocalizedString>) -> Self {
143        self.label = Some(label.into());
144        self
145    }
146
147    /// Replace the default Light/Dark fixed-theme list with an app-supplied set
148    /// of `(label, theme)` pairs — e.g. the `teksilo-theme-*` presets. The
149    /// System (follow-OS) entry is still appended unless [`system`](Self::system)
150    /// is `false`.
151    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    /// Whether to offer the "System" (follow-OS) entry. Default `true`.
165    pub fn system(mut self, include: bool) -> Self {
166        self.include_system = include;
167        self
168    }
169
170    /// Attach a plain tooltip, forwarded to the inner [`ComboBox`].
171    /// Mutually exclusive with the rich / composite variants — last
172    /// call wins.
173    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    /// Attach a rich tooltip resolved from the app-wide registry,
181    /// forwarded to the inner [`ComboBox`]. Overrides any previously
182    /// set tooltip.
183    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    /// Attach a rich tooltip driven by inline
191    /// [`TooltipContent`](crate::tooltip::TooltipContent), forwarded to
192    /// the inner [`ComboBox`]. Overrides any previously set tooltip.
193    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    /// Attach a composite tooltip hosting an arbitrary widget tree,
201    /// forwarded to the inner [`ComboBox`]. Overrides any previously
202    /// set tooltip.
203    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    /// Build the `(display, id, action)` entries for the current configuration.
211    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                // `tr_widget!` already falls back to the English literal when no
227                // manager is installed, so `resolve_now()` yields "Light"/"Dark"
228                // without a separate fallback. (The visible item labels are
229                // re-derived reactively in `build()` via `default_label`.)
230                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        // id → action lookup for on_select (a handful of entries; linear scan).
264        let actions: Rc<Vec<(ThemeId, ThemeAction)>> = Rc::new(
265            entries
266                .into_iter()
267                .map(|(_, id, action)| (id, action))
268                .collect(),
269        );
270
271        // Seed the selection from the active theme's id so the closed combo
272        // shows the current theme.
273        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        // Pass the reactive `LocalizedString` straight through (don't pre-resolve
278        // with `lit!`, which would freeze the control label at the build-time
279        // locale). The AT tree re-walks on a locale change and re-resolves it.
280        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 entries re-derive their label from the id so the
289                // visible item text follows a locale change; custom themes use
290                // the app-supplied (already-resolved) label.
291                default_label(c.id.as_str())
292                    .unwrap_or_else(|| LocalizedString::literal(c.display.clone()))
293            })
294            .variant(self.variant)
295            .label(label)
296            // The reason this widget needs `ComboBox::on_select` (not a plain signal
297            // observer): both `set_theme` and `follow_system_theme` live on
298            // `EventContext`, which `ctx.effect` can't provide.
299            .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        // Forward any configured tooltip onto the inner ComboBox. The
309        // three setters are mutually exclusive, so exactly one branch
310        // runs (last-call-wins, mirroring the ComboBox surface).
311        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        // Keep the selection aligned if the theme changes elsewhere (a menu,
326        // the inspector, an OS light/dark toggle). Matched by stable id.
327        {
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        // The inner ComboBox carries the control role + label.
367    }
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        // No I18nManager installed in tests → English literal fallback.
386        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        // A `.tooltip(..)` on the switcher must reach the inner ComboBox
408        // and appear on hover.
409        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    // The key handler lives on the inner ComboBox; focus it directly.
428    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        // Seeded at Light (entry 0); ArrowDown commits Dark (entry 1), firing
444        // on_select → set_theme, which parks a pending theme request.
445        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        // Not instant — the tooltip waits for the hover delay.
472        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        // Light → Dark → System: two ArrowDowns land on System.
490        tree.press_key(
491            teksilo_core::event::Key::ArrowDown,
492            teksilo_core::event::Modifiers::NONE,
493        );
494        let _ = tree.take_pending_theme_request(); // clear the Dark request
495        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}