gioser

tawasuyu/llimphi

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

Rust 660Markdown 302TOML 157RON 312382 Definiciones

⎇ c1b11b2c01d7623a4f346519b9d1ec435be30b95 llimphi-theme/src/lib.rs Rust · 361 líneas · 14.3 KB Historia Quién tocó qué crudo

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

1//! `llimphi-theme` — paleta compartida entre apps Llimphi.
2//!
3//! Define un set de slots semánticos (`bg_app`, `fg_text`, `accent`, etc.)
4//! que cada widget mapea a su propio `Palette` específico vía
5//! `Palette::from_theme(&theme)`. El analógo Llimphi al `nahual-theme`
6//! GPUI, pero con colores `peniko::Color` y sin macros de Background /
7//! gradiente — Llimphi pinta colores sólidos por ahora.
8//!
9//! Disponer del Theme en un crate aparte permite:
10//! 1. **Consistencia visual**: las apps comparten paleta sin redefinirla.
11//! 2. **Temas intercambiables**: `Theme::dark()` vs `Theme::light()` (o
12//!    más adelante, sobreescritos por config del usuario).
13//! 3. **Widgets desacoplados**: cada widget acepta su `Palette` (no el
14//!    Theme entero), así un consumidor que sólo necesita un botón con
15//!    colores no-temáticos puede construir su `ButtonPalette` a mano.
16
17#![forbid(unsafe_code)]
18
19pub use llimphi_raster::peniko::Color;
20
21use std::time::Duration;
22
23// =====================================================================
24// Tokens transversales — motion, alpha, radius
25// =====================================================================
26//
27// Los widgets de elegancia (tooltip, toast, modal, spinner, splash, …)
28// comparten **duraciones**, **alphas** y **radios** para que el sistema
29// se sienta uno solo. Cada token es `const`: las apps pueden referenciar
30// `motion::NORMAL`/`alpha::SCRIM` directamente, o tomarlos del `Theme`
31// vía `theme.motion()` / `theme.alpha()` / `theme.radius()` cuando una
32// future variante por preset lo requiera.
33
34/// Duraciones canónicas (segundo nivel: rítmico, no nervioso, no
35/// soporífero). Los widgets eligen `FAST` para microinteracciones
36/// (hover, focus), `NORMAL` para transiciones principales (toast entrar,
37/// modal abrir) y `SLOW` para énfasis o entradas dramáticas (splash de
38/// boot).
39pub mod motion {
40    use super::Duration;
41
42    pub const FAST: Duration = Duration::from_millis(80);
43    pub const NORMAL: Duration = Duration::from_millis(160);
44    pub const SLOW: Duration = Duration::from_millis(320);
45
46    /// Easing estándar — cubic-out. Energía inicial, asentamiento suave.
47    /// La gran mayoría de transiciones de salida / aparición.
48    #[inline]
49    pub fn ease_out_cubic(t: f32) -> f32 {
50        let inv = 1.0 - t.clamp(0.0, 1.0);
51        1.0 - inv * inv * inv
52    }
53
54    /// Easing énfasis — cubic-in-out. Para movimientos que cruzan la
55    /// pantalla y necesitan acentuar el centro (modales, splashes).
56    #[inline]
57    pub fn ease_in_out_cubic(t: f32) -> f32 {
58        let t = t.clamp(0.0, 1.0);
59        if t < 0.5 {
60            4.0 * t * t * t
61        } else {
62            let f = -2.0 * t + 2.0;
63            1.0 - f * f * f / 2.0
64        }
65    }
66
67    /// Lineal — no es elegante pero a veces es lo correcto (barra de
68    /// progreso, valores numéricos crudos).
69    #[inline]
70    pub fn linear(t: f32) -> f32 {
71        t.clamp(0.0, 1.0)
72    }
73}
74
75/// Valores de opacidad alfa (0–255) para capas semánticas. Usar siempre
76/// que se quiera *transparencia coherente*. El widget que improvisa su
77/// propio alpha rompe la firma visual.
78pub mod alpha {
79    /// Scrim que cubre la app cuando hay overlay (menú/modal/picker).
80    /// Apaga el fondo lo justo para que el overlay tenga jerarquía,
81    /// sin ocultar contexto.
82    pub const SCRIM: u8 = 64;
83
84    /// Tinte aplicado a un panel "vidrio" sobre fondo activo (tooltip,
85    /// status hint). Casi opaco pero deja respirar.
86    pub const GLASS_PANEL: u8 = 232;
87
88    /// Elementos deshabilitados — visibles pero con menos peso.
89    pub const DISABLED: u8 = 140;
90
91    /// Hint sutil (text watermark, ghost) — apenas legible.
92    pub const HINT: u8 = 96;
93}
94
95/// Radios de esquina canónicos. La elegancia se construye en escalera:
96/// `XS` para chips e inputs, `SM` para botones, `MD` para paneles,
97/// `LG` para superficies grandes (toast, modal, card destacada).
98pub mod radius {
99    pub const XS: f64 = 2.0;
100    pub const SM: f64 = 4.0;
101    pub const MD: f64 = 8.0;
102    pub const LG: f64 = 12.0;
103    pub const XL: f64 = 20.0;
104}
105
106/// Paleta de la app. Slots semánticos que cubren los casos comunes
107/// (fondo, texto, hover, foco, acento). Los widgets reusables toman su
108/// `Palette` específico desde acá vía `Palette::from_theme(&theme)`.
109#[derive(Debug, Clone, Copy)]
110pub struct Theme {
111    /// Nombre legible del preset — alimenta `Theme::by_name`,
112    /// `next_after`, y los UIs que ciclan presets (theme-switcher).
113    pub name: &'static str,
114
115    // --- Fondos ---
116    /// Fondo de la ventana / superficie raíz.
117    pub bg_app: Color,
118    /// Fondo de paneles (sidebars, cards).
119    pub bg_panel: Color,
120    /// Fondo alternativo para barras / strips (tab bar, status bar).
121    pub bg_panel_alt: Color,
122    /// Fondo de campos de input (texto editable).
123    pub bg_input: Color,
124    /// Fondo de input cuando tiene foco.
125    pub bg_input_focus: Color,
126    /// Fondo de botón (chip).
127    pub bg_button: Color,
128    /// Fondo de botón al hover.
129    pub bg_button_hover: Color,
130    /// Fondo de la fila/item seleccionado (lista, tree).
131    pub bg_selected: Color,
132    /// Fondo de fila al hover (sin selección).
133    pub bg_row_hover: Color,
134
135    // --- Foregrounds (texto) ---
136    pub fg_text: Color,
137    pub fg_muted: Color,
138    pub fg_placeholder: Color,
139    pub fg_destructive: Color,
140
141    // --- Bordes y acento ---
142    pub border: Color,
143    pub border_focus: Color,
144    /// Acento primario — divisores activos, borde de input focado,
145    /// underline del tab activo, etc. Tono único de la app.
146    pub accent: Color,
147}
148
149impl Default for Theme {
150    fn default() -> Self {
151        Self::dark()
152    }
153}
154
155impl Theme {
156    /// Tema oscuro — el default. Análogo al `nahual-theme` dark en su
157    /// versión Llimphi: tonos azulados profundos, acento azul claro.
158    pub const fn dark() -> Self {
159        Self {
160            name: "Dark",
161            bg_app: Color::from_rgba8(14, 16, 22, 255),
162            bg_panel: Color::from_rgba8(22, 26, 36, 255),
163            bg_panel_alt: Color::from_rgba8(18, 22, 30, 255),
164            bg_input: Color::from_rgba8(16, 20, 28, 255),
165            bg_input_focus: Color::from_rgba8(20, 26, 38, 255),
166            bg_button: Color::from_rgba8(36, 42, 56, 255),
167            bg_button_hover: Color::from_rgba8(54, 64, 86, 255),
168            bg_selected: Color::from_rgba8(58, 78, 128, 255),
169            bg_row_hover: Color::from_rgba8(36, 44, 60, 255),
170            fg_text: Color::from_rgba8(214, 222, 232, 255),
171            fg_muted: Color::from_rgba8(140, 152, 170, 255),
172            fg_placeholder: Color::from_rgba8(95, 105, 122, 255),
173            fg_destructive: Color::from_rgba8(220, 110, 110, 255),
174            border: Color::from_rgba8(46, 54, 70, 255),
175            border_focus: Color::from_rgba8(110, 140, 220, 255),
176            accent: Color::from_rgba8(110, 140, 220, 255),
177        }
178    }
179
180    /// Tema claro — contraste revisado para WCAG AA sobre `bg_app`:
181    /// `fg_text` ~12:1, `fg_muted` ~5.4:1 (texto secundario legible),
182    /// `fg_destructive` y `accent` oscurecidos para superar 4.5:1 sobre
183    /// fondos claros. `fg_placeholder` queda deliberadamente tenue
184    /// (hint, no contenido).
185    pub const fn light() -> Self {
186        Self {
187            name: "Light",
188            bg_app: Color::from_rgba8(244, 246, 250, 255),
189            bg_panel: Color::from_rgba8(232, 236, 242, 255),
190            bg_panel_alt: Color::from_rgba8(224, 230, 240, 255),
191            bg_input: Color::from_rgba8(255, 255, 255, 255),
192            bg_input_focus: Color::from_rgba8(250, 252, 255, 255),
193            bg_button: Color::from_rgba8(220, 226, 236, 255),
194            bg_button_hover: Color::from_rgba8(200, 210, 226, 255),
195            bg_selected: Color::from_rgba8(160, 180, 220, 255),
196            bg_row_hover: Color::from_rgba8(214, 222, 236, 255),
197            fg_text: Color::from_rgba8(24, 32, 45, 255),
198            fg_muted: Color::from_rgba8(86, 98, 116, 255),
199            fg_placeholder: Color::from_rgba8(140, 150, 168, 255),
200            fg_destructive: Color::from_rgba8(168, 48, 48, 255),
201            border: Color::from_rgba8(190, 199, 214, 255),
202            border_focus: Color::from_rgba8(48, 92, 196, 255),
203            accent: Color::from_rgba8(48, 92, 196, 255),
204        }
205    }
206
207    /// Tema "Aurora" — verdes nocturnos con acento aqua. Análogo al
208    /// preset del nahual-theme.
209    pub const fn aurora() -> Self {
210        Self {
211            name: "Aurora",
212            bg_app: Color::from_rgba8(8, 18, 22, 255),
213            bg_panel: Color::from_rgba8(14, 28, 34, 255),
214            bg_panel_alt: Color::from_rgba8(12, 24, 30, 255),
215            bg_input: Color::from_rgba8(10, 22, 28, 255),
216            bg_input_focus: Color::from_rgba8(14, 30, 38, 255),
217            bg_button: Color::from_rgba8(20, 44, 52, 255),
218            bg_button_hover: Color::from_rgba8(30, 66, 78, 255),
219            bg_selected: Color::from_rgba8(30, 90, 100, 255),
220            bg_row_hover: Color::from_rgba8(20, 46, 56, 255),
221            fg_text: Color::from_rgba8(214, 232, 232, 255),
222            fg_muted: Color::from_rgba8(130, 168, 168, 255),
223            fg_placeholder: Color::from_rgba8(90, 120, 120, 255),
224            fg_destructive: Color::from_rgba8(220, 110, 110, 255),
225            border: Color::from_rgba8(38, 70, 78, 255),
226            border_focus: Color::from_rgba8(80, 200, 200, 255),
227            accent: Color::from_rgba8(80, 200, 200, 255),
228        }
229    }
230
231    /// Tema "Sunset" — cálidos con acento naranja, sobre base oscura.
232    pub const fn sunset() -> Self {
233        Self {
234            name: "Sunset",
235            bg_app: Color::from_rgba8(22, 14, 14, 255),
236            bg_panel: Color::from_rgba8(34, 22, 22, 255),
237            bg_panel_alt: Color::from_rgba8(28, 18, 18, 255),
238            bg_input: Color::from_rgba8(28, 18, 18, 255),
239            bg_input_focus: Color::from_rgba8(36, 24, 22, 255),
240            bg_button: Color::from_rgba8(54, 34, 28, 255),
241            bg_button_hover: Color::from_rgba8(78, 50, 38, 255),
242            bg_selected: Color::from_rgba8(120, 64, 38, 255),
243            bg_row_hover: Color::from_rgba8(56, 36, 28, 255),
244            fg_text: Color::from_rgba8(238, 220, 200, 255),
245            fg_muted: Color::from_rgba8(174, 142, 120, 255),
246            fg_placeholder: Color::from_rgba8(120, 96, 80, 255),
247            fg_destructive: Color::from_rgba8(220, 100, 100, 255),
248            border: Color::from_rgba8(70, 46, 36, 255),
249            border_focus: Color::from_rgba8(232, 140, 70, 255),
250            accent: Color::from_rgba8(232, 140, 70, 255),
251        }
252    }
253
254    /// Tema "Print" — blanco y negro de alto contraste para impresión.
255    /// Fondo blanco papel, tinta negra, sin grises decorativos: todo lo
256    /// que se imprime tiene que leerse en una fotocopiadora. `fg_muted`
257    /// es un gris medio (3.5:1) reservado a metadatos; el cuerpo va en
258    /// negro puro. Acento y bordes negros — la tinta es una sola.
259    pub const fn print() -> Self {
260        Self {
261            name: "Print",
262            bg_app: Color::from_rgba8(255, 255, 255, 255),
263            bg_panel: Color::from_rgba8(255, 255, 255, 255),
264            bg_panel_alt: Color::from_rgba8(246, 246, 246, 255),
265            bg_input: Color::from_rgba8(255, 255, 255, 255),
266            bg_input_focus: Color::from_rgba8(248, 248, 248, 255),
267            bg_button: Color::from_rgba8(238, 238, 238, 255),
268            bg_button_hover: Color::from_rgba8(224, 224, 224, 255),
269            bg_selected: Color::from_rgba8(220, 220, 220, 255),
270            bg_row_hover: Color::from_rgba8(240, 240, 240, 255),
271            fg_text: Color::from_rgba8(0, 0, 0, 255),
272            fg_muted: Color::from_rgba8(90, 90, 90, 255),
273            fg_placeholder: Color::from_rgba8(140, 140, 140, 255),
274            fg_destructive: Color::from_rgba8(0, 0, 0, 255),
275            border: Color::from_rgba8(0, 0, 0, 255),
276            border_focus: Color::from_rgba8(0, 0, 0, 255),
277            accent: Color::from_rgba8(0, 0, 0, 255),
278        }
279    }
280
281    /// Todos los presets del repo, en el orden canónico de rotación
282    /// (Dark → Light → Aurora → Sunset → Dark…). El theme-switcher
283    /// los consume vía [`Theme::next_after`]. `print()` queda fuera de la
284    /// rotación a propósito — es un modo deliberado (imprimir), no un
285    /// gusto estético que se cicle por accidente.
286    pub fn all() -> Vec<Self> {
287        vec![Self::dark(), Self::light(), Self::aurora(), Self::sunset()]
288    }
289
290    /// Busca un preset por nombre exacto.
291    pub fn by_name(name: &str) -> Option<Self> {
292        Self::all().into_iter().find(|t| t.name == name)
293    }
294
295    /// Próximo preset en la rotación de [`Theme::all`]. Si `current` no
296    /// se encuentra, retorna el primero — el switcher nunca se traba.
297    pub fn next_after(current: &str) -> Self {
298        let all = Self::all();
299        let idx = all
300            .iter()
301            .position(|t| t.name == current)
302            .map(|i| (i + 1) % all.len())
303            .unwrap_or(0);
304        all[idx]
305    }
306}
307
308#[cfg(test)]
309mod tests {
310    use super::*;
311
312    #[test]
313    fn presets_have_unique_names() {
314        let all = Theme::all();
315        let mut names: Vec<&str> = all.iter().map(|t| t.name).collect();
316        let n_before = names.len();
317        names.sort();
318        names.dedup();
319        assert_eq!(names.len(), n_before, "nombres duplicados en Theme::all()");
320    }
321
322    #[test]
323    fn by_name_finds_each_preset() {
324        for t in Theme::all() {
325            let by = Theme::by_name(t.name).expect("preset registrado");
326            assert_eq!(by.name, t.name);
327        }
328    }
329
330    #[test]
331    fn by_name_returns_none_for_unknown() {
332        assert!(Theme::by_name("ThisDoesNotExist").is_none());
333    }
334
335    #[test]
336    fn next_after_cycles_through_all_presets() {
337        let all = Theme::all();
338        let mut current = all[0].name;
339        let mut visited = vec![current];
340        for _ in 0..all.len() - 1 {
341            current = Theme::next_after(current).name;
342            visited.push(current);
343        }
344        let names: Vec<&str> = all.iter().map(|t| t.name).collect();
345        assert_eq!(visited, names);
346        // El siguiente debe volver al primero.
347        let wrapped = Theme::next_after(current).name;
348        assert_eq!(wrapped, all[0].name);
349    }
350
351    #[test]
352    fn next_after_unknown_falls_back_to_first() {
353        let n = Theme::next_after("Nope").name;
354        assert_eq!(n, Theme::all()[0].name);
355    }
356
357    #[test]
358    fn dark_is_the_default() {
359        assert_eq!(Theme::default().name, "Dark");
360    }
361}