tawasuyu/llimphi
Sovereign retained-mode GPU UI framework in Rust (wgpu+vello+taffy+parley), Elm loop, 40+ widgets.
⎇ a3c2ea9ffa5a97812e9ec043374b756586210bfb
widgets/theme-switcher/src/lib.rs
Historia
Quién tocó qué
crudo
feat: llimphi standalone — framework UI soberano extraído del monorepo
1//! `llimphi-widget-theme-switcher` — botón que rota los presets de
2//! [`llimphi_theme::Theme`].
3//!
4//! Análogo Llimphi del `nahual-widget-theme-switcher` GPUI. Diferencia
5//! estructural: GPUI lleva el theme en un `Global` y el switcher lo
6//! reemplaza con `cx.set_global`; Llimphi no tiene globals — el caller
7//! guarda el theme en su `Model` y reasigna en su `update`. El widget
8//! sólo emite `on_change(next_theme)` cuando el botón se clickea, donde
9//! `next_theme` es el siguiente preset de [`Theme::next_after`].
10//!
11//! El label del botón muestra el nombre del preset actual con un signo
12//! de rotación (`Tema: Dark ▸`). Los colores salen del `Theme` actual
13//! para que el switcher sea coherente con el resto de la UI.
14//!
15//! # Uso
16//!
17//! ```ignore
18//! use llimphi_widget_theme_switcher::theme_switcher_view;
19//!
20//! // En App::view:
21//! let switcher = theme_switcher_view(&model.theme, Msg::ChangeTheme);
22//! ```
23//!
24//! `Msg::ChangeTheme(Theme)` lo define la app; en `update`:
25//!
26//! ```ignore
27//! Msg::ChangeTheme(t) => { model.theme = t; }
28//! ```
29
30#![forbid(unsafe_code)]
31
32use llimphi_theme::Theme;
33use llimphi_ui::llimphi_layout::taffy::{
34 prelude::{length, percent, AlignItems, JustifyContent, Size, Style},
35 Rect,
36};
37use llimphi_ui::llimphi_raster::peniko::Color;
38use llimphi_ui::llimphi_text::Alignment;
39use llimphi_ui::View;
40
41/// Paleta del switcher. Por default replica el patrón del switcher de
42/// nahual: `bg_panel_alt` + hover `bg_row_hover`, texto `fg_text`.
43#[derive(Debug, Clone, Copy)]
44pub struct ThemeSwitcherPalette {
45 pub bg: Color,
46 pub bg_hover: Color,
47 pub fg: Color,
48 pub radius: f64,
49}
50
51impl Default for ThemeSwitcherPalette {
52 fn default() -> Self {
53 Self::from_theme(&Theme::dark())
54 }
55}
56
57impl ThemeSwitcherPalette {
58 pub fn from_theme(t: &Theme) -> Self {
59 Self {
60 bg: t.bg_panel_alt,
61 bg_hover: t.bg_row_hover,
62 fg: t.fg_text,
63 radius: 3.0,
64 }
65 }
66}
67
68/// Compone el switcher: chip con texto `Tema: <nombre> ▸`. Click rota
69/// al siguiente preset y emite `on_change(next)`.
70///
71/// Toma el `current` por referencia para no clonar el `Theme` entero
72/// (es `Copy`, pero la API se mantiene consistente con `Palette::from_theme`).
73/// La paleta se deriva del `current` para que el chip use el mismo set
74/// de colores que el resto de la UI.
75pub fn theme_switcher_view<Msg: Clone + 'static>(
76 current: &Theme,
77 on_change: impl Fn(Theme) -> Msg,
78) -> View<Msg> {
79 let palette = ThemeSwitcherPalette::from_theme(current);
80 theme_switcher_styled(current, &palette, on_change)
81}
82
83/// Variante con paleta explícita — útil cuando la app quiere un look
84/// distinto al default (botón destacado, accent del switcher fijo, etc.).
85pub fn theme_switcher_styled<Msg: Clone + 'static>(
86 current: &Theme,
87 palette: &ThemeSwitcherPalette,
88 on_change: impl Fn(Theme) -> Msg,
89) -> View<Msg> {
90 let next = Theme::next_after(current.name);
91 let label = format!("Tema: {} ▸", current.name);
92
93 View::new(Style {
94 size: Size {
95 width: length(140.0_f32),
96 height: length(26.0_f32),
97 },
98 padding: Rect {
99 left: length(10.0_f32),
100 right: length(10.0_f32),
101 top: length(0.0_f32),
102 bottom: length(0.0_f32),
103 },
104 align_items: Some(AlignItems::Center),
105 justify_content: Some(JustifyContent::Center),
106 ..Default::default()
107 })
108 .fill(palette.bg)
109 .hover_fill(palette.bg_hover)
110 .radius(palette.radius)
111 .text_aligned(label, 11.0, palette.fg, Alignment::Start)
112 .on_click(on_change(next))
113}
114
115/// Variante de tamaño flexible — toma el ancho dado por el padre y se
116/// adapta al alto natural del slot. Útil dentro de toolbars con flexbox.
117pub fn theme_switcher_flex<Msg: Clone + 'static>(
118 current: &Theme,
119 palette: &ThemeSwitcherPalette,
120 on_change: impl Fn(Theme) -> Msg,
121) -> View<Msg> {
122 let next = Theme::next_after(current.name);
123 let label = format!("Tema: {} ▸", current.name);
124
125 View::new(Style {
126 size: Size {
127 width: percent(1.0_f32),
128 height: length(26.0_f32),
129 },
130 padding: Rect {
131 left: length(10.0_f32),
132 right: length(10.0_f32),
133 top: length(0.0_f32),
134 bottom: length(0.0_f32),
135 },
136 align_items: Some(AlignItems::Center),
137 ..Default::default()
138 })
139 .fill(palette.bg)
140 .hover_fill(palette.bg_hover)
141 .radius(palette.radius)
142 .text_aligned(label, 11.0, palette.fg, Alignment::Start)
143 .on_click(on_change(next))
144}
145
146#[cfg(test)]
147mod tests {
148 use super::*;
149
150 #[derive(Debug, Clone, PartialEq)]
151 enum Msg {
152 Change(&'static str),
153 }
154
155 #[test]
156 fn switcher_constructs_with_a_default_theme() {
157 let t = Theme::dark();
158 let _v = theme_switcher_view::<Msg>(&t, |th| Msg::Change(th.name));
159 // Si el constructor no panicó, el widget queda armado.
160 }
161
162 #[test]
163 fn palette_from_theme_matches_panel_alt_slots() {
164 let t = Theme::dark();
165 let p = ThemeSwitcherPalette::from_theme(&t);
166 // No comparamos por igualdad de Color (no implementa PartialEq);
167 // sí garantizamos que la paleta derivó del theme — radius default.
168 assert_eq!(p.radius, 3.0);
169 }
170
171 #[test]
172 fn on_change_receives_the_next_preset() {
173 // Verificación funcional independiente: la rotación que verá el
174 // handler coincide con `Theme::next_after`.
175 let current = Theme::dark();
176 let expected_next = Theme::next_after(current.name).name;
177 assert_eq!(expected_next, "Light");
178 }
179}