teksilo-widgets 0.9.0

Widget library for Teksilo — over a hundred widgets and layout primitives, from Button to TreeTableView.
Documentation
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
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
// SPDX-License-Identifier: MPL-2.0
// SPDX-FileCopyrightText: 2026 FernTech

//! ThemeSwitcher — a drop-in app-theme picker for settings screens & toolbars.
//!
//! A thin [`ComboBox`] preset that switches the application theme. By default
//! it offers three entries — **Light**, **Dark**, and **System** — where
//! *System* follows the native OS theme live: it adopts the OS's actual colours
//! (GNOME / KDE / Cinnamon on Linux) and tracks OS light/dark changes at
//! runtime, falling back to the built-in light/dark presets on platforms
//! without OS-colour support.
//!
//! Zero-config: drop `ThemeSwitcher::new()` into a settings panel or toolbar and
//! it
//!
//! - shows the active theme as the current selection (matched by the theme's
//!   stable [`ThemeId`]),
//! - switches the app theme on selection via `EventContext::set_theme` (fixed
//!   themes) or `EventContext::follow_system_theme` (System),
//! - and stays in sync if the theme changes elsewhere (a menu, the inspector,
//!   or an OS light/dark toggle).
//!
//! ```ignore
//! // In a settings panel or toolbar:
//! Toolbar::new().child(HStack::new().child(Spacer::new()).child(ThemeSwitcher::new()))
//! ```
//!
//! Labels are **translated** via the framework Fluent bundle (`tr_widget!`),
//! with an English literal fallback so a host app that hasn't installed an
//! `I18nManager` still reads "Light / Dark / System" rather than raw keys.
//!
//! Custom themes: `.themes([(label, theme), …])` replaces Light/Dark with an
//! app-supplied set (e.g. the `teksilo-theme-{fluent,macos,material3}` presets);
//! `.system(false)` drops the System entry.

use std::rc::Rc;

use teksilo_canvas::{Rect, SizeProposal};
use teksilo_core::accessibility::AccessNodeBuilder;
use teksilo_core::build_context::BuildContext;
use teksilo_core::signal::Signal;
use teksilo_core::styles::{Theme, ThemeId};
use teksilo_core::widget::{LayoutContext, LayoutResponse, Widget, WidgetPlacement};
use teksilo_core::widget_id::WidgetId;
use teksilo_i18n::{LocalizedString, tr_widget};

use crate::combo_box::{ComboBox, ComboBoxVariant};

/// One row in the switcher: the theme's stable [`ThemeId`] (used to match the
/// active theme and to look up the action) and the user-facing `display`
/// string. `ThemeId` `"system"` denotes the follow-OS entry.
#[derive(Clone, PartialEq)]
struct ThemeChoice {
    id: ThemeId,
    display: String,
}

/// What selecting an entry does: pin a fixed theme, or follow the OS.
/// The fixed theme is boxed because `Theme` is large relative to the
/// zero-size `FollowSystem` variant.
#[derive(Clone)]
enum ThemeAction {
    Set(Box<Theme>),
    FollowSystem,
}

/// A drop-in app-theme picker built on [`ComboBox`]. See the module docs.
pub struct ThemeSwitcher {
    /// Forwarded to the inner [`ComboBox`]. Defaults to `Outlined`.
    variant: ComboBoxVariant,
    /// Accessible / control label. Defaults to the translated "Theme".
    label: Option<LocalizedString>,
    /// Explicit fixed-theme list `(label, theme)`. When `None` (the default),
    /// the switcher offers Light + Dark.
    themes_override: Option<Vec<(LocalizedString, Theme)>>,
    /// Whether to append a "System" (follow-OS) entry. Default `true`.
    include_system: bool,
    /// The inner ComboBox's value signal. Owned here so the theme-sync effect
    /// can keep it aligned with the active theme.
    selected: Signal<Option<ThemeChoice>>,
    /// 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 ThemeSwitcher {
    fn default() -> Self {
        Self::new()
    }
}

impl std::fmt::Debug for ThemeSwitcher {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        f.debug_struct("ThemeSwitcher")
            .field("variant", &self.variant)
            .field("include_system", &self.include_system)
            .finish()
    }
}

/// The translated label for a default entry, by theme id. Returns a reactive
/// [`LocalizedString`] (from `tr_widget!`) that re-resolves on locale change;
/// without an `I18nManager` the macro itself falls back to the English literal.
fn default_label(id: &str) -> Option<LocalizedString> {
    match id {
        "intui.light" => Some(tr_widget!(theme_switcher_light())),
        "intui.dark" => Some(tr_widget!(theme_switcher_dark())),
        "system" => Some(tr_widget!(theme_switcher_system())),
        _ => None,
    }
}

impl ThemeSwitcher {
    /// Create a switcher offering Light / Dark / System (the System entry
    /// follows the OS theme live).
    pub fn new() -> Self {
        Self {
            variant: ComboBoxVariant::default(),
            label: None,
            themes_override: None,
            include_system: true,
            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 the translated "Theme").
    pub fn label(mut self, label: impl Into<LocalizedString>) -> Self {
        self.label = Some(label.into());
        self
    }

    /// Replace the default Light/Dark fixed-theme list with an app-supplied set
    /// of `(label, theme)` pairs — e.g. the `teksilo-theme-*` presets. The
    /// System (follow-OS) entry is still appended unless [`system`](Self::system)
    /// is `false`.
    pub fn themes(
        mut self,
        themes: impl IntoIterator<Item = (impl Into<LocalizedString>, Theme)>,
    ) -> Self {
        self.themes_override = Some(
            themes
                .into_iter()
                .map(|(label, theme)| (label.into(), theme))
                .collect(),
        );
        self
    }

    /// Whether to offer the "System" (follow-OS) entry. Default `true`.
    pub fn system(mut self, include: bool) -> Self {
        self.include_system = include;
        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 `(display, id, action)` entries for the current configuration.
    fn entries(&self) -> Vec<(String, ThemeId, ThemeAction)> {
        let mut out: Vec<(String, ThemeId, ThemeAction)> = Vec::new();
        match &self.themes_override {
            Some(custom) => {
                for (label, theme) in custom {
                    out.push((
                        label.resolve_now(),
                        theme.id.clone(),
                        ThemeAction::Set(Box::new(theme.clone())),
                    ));
                }
            }
            None => {
                let light = teksilo_core::presets::intui::light();
                let dark = teksilo_core::presets::intui::dark();
                // `tr_widget!` already falls back to the English literal when no
                // manager is installed, so `resolve_now()` yields "Light"/"Dark"
                // without a separate fallback. (The visible item labels are
                // re-derived reactively in `build()` via `default_label`.)
                out.push((
                    tr_widget!(theme_switcher_light()).resolve_now(),
                    light.id.clone(),
                    ThemeAction::Set(Box::new(light)),
                ));
                out.push((
                    tr_widget!(theme_switcher_dark()).resolve_now(),
                    dark.id.clone(),
                    ThemeAction::Set(Box::new(dark)),
                ));
            }
        }
        if self.include_system {
            out.push((
                tr_widget!(theme_switcher_system()).resolve_now(),
                ThemeId::new("system"),
                ThemeAction::FollowSystem,
            ));
        }
        out
    }
}

impl Widget for ThemeSwitcher {
    fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
        let entries = self.entries();
        let choices: Vec<ThemeChoice> = entries
            .iter()
            .map(|(display, id, _)| ThemeChoice {
                id: id.clone(),
                display: display.clone(),
            })
            .collect();
        // id → action lookup for on_select (a handful of entries; linear scan).
        let actions: Rc<Vec<(ThemeId, ThemeAction)>> = Rc::new(
            entries
                .into_iter()
                .map(|(_, id, action)| (id, action))
                .collect(),
        );

        // Seed the selection from the active theme's id so the closed combo
        // shows the current theme.
        let current_id = ctx.theme().id.clone();
        let initial = choices.iter().find(|c| c.id == current_id).cloned();
        self.selected.set(initial);

        // Pass the reactive `LocalizedString` straight through (don't pre-resolve
        // with `lit!`, which would freeze the control label at the build-time
        // locale). The AT tree re-walks on a locale change and re-resolves it.
        let label = self
            .label
            .clone()
            .unwrap_or_else(|| tr_widget!(theme_switcher_label()));

        let on_select_actions = actions.clone();
        let mut combo =
            ComboBox::from_items(choices.clone(), self.selected.clone(), |c: &ThemeChoice| {
                // Default entries re-derive their label from the id so the
                // visible item text follows a locale change; custom themes use
                // the app-supplied (already-resolved) label.
                default_label(c.id.as_str())
                    .unwrap_or_else(|| LocalizedString::literal(c.display.clone()))
            })
            .variant(self.variant)
            .label(label)
            // The reason this widget needs `ComboBox::on_select` (not a plain signal
            // observer): both `set_theme` and `follow_system_theme` live on
            // `EventContext`, which `ctx.effect` can't provide.
            .on_select(move |c: &ThemeChoice, ctx| {
                if let Some((_, action)) = on_select_actions.iter().find(|(id, _)| *id == c.id) {
                    match action {
                        ThemeAction::Set(theme) => ctx.set_theme((**theme).clone()),
                        ThemeAction::FollowSystem => ctx.follow_system_theme(),
                    }
                }
            });

        // 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 theme changes elsewhere (a menu,
        // the inspector, an OS light/dark toggle). Matched by stable id.
        {
            let selected = self.selected.clone();
            let choices = choices.clone();
            ctx.effect(&ctx.theme_signal(), move |theme| {
                let next = choices.iter().find(|c| c.id == theme.id).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())
    }

    #[test]
    fn default_entries_are_light_dark_system() {
        let sw = ThemeSwitcher::new();
        let entries = sw.entries();
        let ids: Vec<&str> = entries.iter().map(|(_, id, _)| id.as_str()).collect();
        assert_eq!(ids, vec!["intui.light", "intui.dark", "system"]);
        // No I18nManager installed in tests → English literal fallback.
        let labels: Vec<&str> = entries.iter().map(|(d, _, _)| d.as_str()).collect();
        assert_eq!(labels, vec!["Light", "Dark", "System"]);
    }

    #[test]
    fn system_can_be_disabled() {
        let entries = ThemeSwitcher::new().system(false).entries();
        let ids: Vec<&str> = entries.iter().map(|(_, id, _)| id.as_str()).collect();
        assert_eq!(ids, vec!["intui.light", "intui.dark"]);
    }

    #[test]
    fn builds_and_lays_out() {
        let mut tree = light_tree();
        let id = tree.add(ThemeSwitcher::new());
        tree.layout(SizeProposal::exact(240.0, 40.0));
        assert!(tree.bounds(id).width > 0.0);
    }

    #[test]
    fn tooltip_forwards_to_inner_combo() {
        // A `.tooltip(..)` on the switcher must reach the inner ComboBox
        // and appear on hover.
        let mut tree = light_tree();
        let id =
            tree.add(ThemeSwitcher::new().tooltip(LocalizedString::literal("Application theme")));
        tree.layout(SizeProposal::exact(240.0, 40.0));

        tree.pointer_move(tree.bounds(id).center());
        tree.advance_time(std::time::Duration::from_secs(1));
        assert_eq!(
            tree.active_overlays().len(),
            1,
            "ThemeSwitcher tooltip should appear on hover"
        );
        assert!(
            tree.find_by_label("Application theme").is_some(),
            "the forwarded tooltip content should be present"
        );
    }

    // The key handler lives on the inner ComboBox; focus it directly.
    fn inner_combo(tree: &WidgetTree, id: WidgetId) -> WidgetId {
        tree.children(id)
            .first()
            .copied()
            .expect("ThemeSwitcher should wrap one ComboBox child")
    }

    #[test]
    fn selecting_dark_row_queues_theme_change() {
        let mut tree = light_tree();
        let id = tree.add(ThemeSwitcher::new());
        tree.layout(SizeProposal::exact(240.0, 240.0));

        let combo = inner_combo(&tree, id);
        tree.focus(combo);
        // Seeded at Light (entry 0); ArrowDown commits Dark (entry 1), firing
        // on_select → set_theme, which parks a pending theme request.
        tree.press_key(
            teksilo_core::event::Key::ArrowDown,
            teksilo_core::event::Modifiers::NONE,
        );
        let pending = tree.take_pending_theme_request();
        assert!(
            pending.is_some(),
            "selecting Dark must queue a theme switch"
        );
        assert_eq!(pending.unwrap().id.as_str(), "intui.dark");
    }

    #[test]
    fn tooltip_appears_after_hover_delay() {
        use std::cell::RefCell;
        use std::time::Duration;
        use teksilo_canvas::MockTextBackend;

        let mut tree = WidgetTree::new()
            .with_theme(teksilo_core::presets::intui::light())
            .with_text_backend(Rc::new(RefCell::new(MockTextBackend::new())));
        let id = tree.add(ThemeSwitcher::new().tooltip(teksilo_i18n::lit!("Pick the app theme")));
        tree.layout(SizeProposal::exact(240.0, 40.0));

        assert!(tree.active_overlays().is_empty());
        tree.pointer_move(tree.bounds(id).center());
        // Not instant — the tooltip waits for the hover delay.
        assert!(tree.active_overlays().is_empty());
        tree.advance_time(Duration::from_secs(2));
        assert_eq!(
            tree.active_overlays().len(),
            1,
            "ThemeSwitcher tooltip should appear after the hover delay"
        );
    }

    #[test]
    fn selecting_system_row_requests_follow_os() {
        let mut tree = light_tree();
        let id = tree.add(ThemeSwitcher::new());
        tree.layout(SizeProposal::exact(240.0, 240.0));

        let combo = inner_combo(&tree, id);
        tree.focus(combo);
        // Light → Dark → System: two ArrowDowns land on System.
        tree.press_key(
            teksilo_core::event::Key::ArrowDown,
            teksilo_core::event::Modifiers::NONE,
        );
        let _ = tree.take_pending_theme_request(); // clear the Dark request
        tree.press_key(
            teksilo_core::event::Key::ArrowDown,
            teksilo_core::event::Modifiers::NONE,
        );
        assert!(
            tree.take_pending_follow_system_request(),
            "selecting System must request follow-OS mode"
        );
    }
}