tawasuyu

tawasuyu/llimphi

Sovereign retained-mode GPU UI framework in Rust (wgpu+vello+taffy+parley), Elm loop, 40+ widgets.

Rust 660Markdown 302TOML 157RON 312382 Definiciones

⎇ a3c2ea9ffa5a97812e9ec043374b756586210bfb widgets/theme-switcher/src/lib.rs Rust · 179 líneas · 5.6 KB Historia Quién tocó qué crudo

feat: llimphi standalone — framework UI soberano extraído del monorepo e65e9cc62 · Sergio ·

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}