gioser

tawasuyu/llimphi

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

Rust 660Markdown 302TOML 157RON 312382 Definitions

⎇ 168cd187519c0ad575d3ce63baee2f164c38d9d1 llimphi-theme/src/lib.rs Rust · 705 lines · 31.0 KB History Who touched what raw

chore: refresco desde el monorepo — vello 0.9 / wgpu 29, 120 miembros d251eb32b · 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// `no_std` salvo bajo test (el arnés de pruebas necesita std). La paleta es
19// datos y aritmética: no había una sola razón para atarla a un sistema
20// operativo, sólo la inercia de heredar `peniko` a través de `llimphi-raster`.
21#![cfg_attr(not(test), no_std)]
22
23extern crate alloc;
24
25use alloc::vec;
26use alloc::vec::Vec;
27
28pub use peniko::Color;
29
30/// Un canal de color en 0,0..=1,0 llevado a byte, redondeando al más cercano.
31///
32/// No usa `f32::round`: ese método lo aporta `std`, y este crate es `no_std`.
33/// La alternativa obvia —`kurbo::common::FloatFuncs`— es una trampa de `cfg`:
34/// kurbo sólo la define cuando SU feature `std` está apagada, y en el build de
35/// Linux vello se la enciende, así que el import compilaría en wawa y fallaría
36/// en Linux. Sumar 0,5 y truncar da el mismo resultado que `round()` para
37/// valores no negativos, que es todo lo que un canal de color puede ser.
38fn a_byte(canal: f32) -> u8 {
39    (canal * 255.0 + 0.5).clamp(0.0, 255.0) as u8
40}
41
42// `core::time::Duration` es EL MISMO tipo que `std::time::Duration` — std sólo
43// lo reexporta. El cambio no toca a ningún consumidor.
44use core::time::Duration;
45
46// =====================================================================
47// Color estable por semilla — avatares, etiquetas, hash-coloring
48// =====================================================================
49
50/// Paleta sobria de 8 tonos para colorear entidades por hash (avatares de
51/// contactos, etiquetas de calendario…). Tonos apagados que conviven con
52/// cualquier `Theme`. Usada vía [`stable_color`].
53pub const ENTITY_PALETTE: [(u8, u8, u8); 8] = [
54    (94, 129, 172),  // azul acero
55    (163, 109, 156), // malva
56    (122, 162, 110), // verde salvia
57    (191, 138, 92),  // terracota
58    (108, 153, 168), // celeste apagado
59    (170, 120, 120), // rosa viejo
60    (130, 140, 175), // lavanda
61    (150, 150, 110), // oliva
62];
63
64/// Color estable derivado de una semilla: hash FNV-1a del texto → índice en
65/// [`ENTITY_PALETTE`]. La misma semilla da siempre el mismo color, sin estado.
66/// Para avatares (por correo), etiquetas, badges de entidad, etc.
67pub fn stable_color(seed: &str) -> Color {
68    let mut h: u32 = 2_166_136_261;
69    for b in seed.bytes() {
70        h ^= b as u32;
71        h = h.wrapping_mul(16_777_619);
72    }
73    let (r, g, b) = ENTITY_PALETTE[(h as usize) % ENTITY_PALETTE.len()];
74    Color::from_rgba8(r, g, b, 255)
75}
76
77// =====================================================================
78// Tokens transversales — motion, alpha, radius
79// =====================================================================
80//
81// Los widgets de elegancia (tooltip, toast, modal, spinner, splash, …)
82// comparten **duraciones**, **alphas** y **radios** para que el sistema
83// se sienta uno solo. Cada token es `const`: las apps pueden referenciar
84// `motion::NORMAL`/`alpha::SCRIM` directamente, o tomarlos del `Theme`
85// vía `theme.motion()` / `theme.alpha()` / `theme.radius()` cuando una
86// future variante por preset lo requiera.
87
88/// Duraciones canónicas (segundo nivel: rítmico, no nervioso, no
89/// soporífero). Los widgets eligen `MICRO` para tintes de hover/focus
90/// que sólo necesitan suavizar el "salto", `FAST` para microinteracciones
91/// completas (chip que pulsa), `NORMAL` para transiciones principales
92/// (toast entrar, modal abrir), `SLOW` para énfasis o entradas dramáticas
93/// (splash de boot, hero shared-element).
94pub mod motion {
95    use super::Duration;
96
97    /// Tintes hover/focus — apenas perceptible, sólo elimina el "clack".
98    pub const MICRO: Duration = Duration::from_millis(50);
99    pub const FAST: Duration = Duration::from_millis(80);
100    pub const NORMAL: Duration = Duration::from_millis(160);
101    pub const SLOW: Duration = Duration::from_millis(320);
102    /// Entradas dramáticas (splash, hero shared-element).
103    pub const DRAMATIC: Duration = Duration::from_millis(480);
104
105    /// Easing estándar — cubic-out. Energía inicial, asentamiento suave.
106    /// La gran mayoría de transiciones de salida / aparición.
107    #[inline]
108    pub fn ease_out_cubic(t: f32) -> f32 {
109        let inv = 1.0 - t.clamp(0.0, 1.0);
110        1.0 - inv * inv * inv
111    }
112
113    /// Easing énfasis — cubic-in-out. Para movimientos que cruzan la
114    /// pantalla y necesitan acentuar el centro (modales, splashes).
115    #[inline]
116    pub fn ease_in_out_cubic(t: f32) -> f32 {
117        let t = t.clamp(0.0, 1.0);
118        if t < 0.5 {
119            4.0 * t * t * t
120        } else {
121            let f = -2.0 * t + 2.0;
122            1.0 - f * f * f / 2.0
123        }
124    }
125
126    /// Easing fuerte — quint-out. Arranca más rápido que cubic-out y
127    /// asienta más suave. Para elementos que aparecen "lanzados" (toast,
128    /// FAB).
129    #[inline]
130    pub fn ease_out_quint(t: f32) -> f32 {
131        let inv = 1.0 - t.clamp(0.0, 1.0);
132        1.0 - inv * inv * inv * inv * inv
133    }
134
135    /// Overshoot suave — back-out con `c1=1.70158` (Material/Penner
136    /// estándar). El valor pasa de 0 al objetivo, lo sobrepasa ~10 % y
137    /// vuelve. Para entradas que necesitan "ping" (modal, snackbar,
138    /// elemento nuevo en una lista). No usar para hover — la oscilación
139    /// se percibe nerviosa.
140    #[inline]
141    pub fn ease_out_back(t: f32) -> f32 {
142        let t = t.clamp(0.0, 1.0);
143        const C1: f32 = 1.701_58;
144        const C3: f32 = C1 + 1.0;
145        let u = t - 1.0;
146        1.0 + C3 * u * u * u + C1 * u * u
147    }
148
149    /// Lineal — no es elegante pero a veces es lo correcto (barra de
150    /// progreso, valores numéricos crudos).
151    #[inline]
152    pub fn linear(t: f32) -> f32 {
153        t.clamp(0.0, 1.0)
154    }
155}
156
157/// Tokens de **elevación** — sombras escalonadas. Como `Shadow` vive en
158/// `llimphi-compositor` (y `llimphi-theme` no depende de él para
159/// quedarse leaf), cada nivel se expone como `(alpha_u8, blur_px,
160/// dy_px)`. Los widgets construyen su `Shadow` puenteándolo:
161/// `Shadow { color: Color::from_rgba8(0,0,0, a), blur, dy, dx: 0.0, spread: 0.0 }`.
162/// Escala perceptual logarítmica: cada nivel ~×2 de blur.
163pub mod elevation {
164    /// `(alpha 0–255, blur px, dy px)`. dy ≈ blur·0.4 (sombra natural,
165    /// fuente de luz un poco arriba).
166    pub type Elev = (u8, f64, f64);
167
168    /// E1 — chip levantado del fondo (hover button, badge).
169    pub const E1: Elev = (44, 4.0, 1.5);
170    /// E2 — card/tile flotante sobre el panel (default cards).
171    pub const E2: Elev = (60, 10.0, 4.0);
172    /// E3 — superficie destacada (menú contextual, dropdown).
173    pub const E3: Elev = (84, 18.0, 8.0);
174    /// E4 — overlay sobre la app (modal, dialog).
175    pub const E4: Elev = (110, 32.0, 14.0);
176    /// E5 — sello de identidad (FAB, hero, picker activo).
177    pub const E5: Elev = (140, 48.0, 22.0);
178}
179
180/// Valores de opacidad alfa (0–255) para capas semánticas. Usar siempre
181/// que se quiera *transparencia coherente*. El widget que improvisa su
182/// propio alpha rompe la firma visual.
183pub mod alpha {
184    /// Scrim que cubre la app cuando hay overlay (menú/modal/picker).
185    /// Apaga el fondo lo justo para que el overlay tenga jerarquía,
186    /// sin ocultar contexto.
187    pub const SCRIM: u8 = 64;
188
189    /// Tinte aplicado a un panel "vidrio" sobre fondo activo (tooltip,
190    /// status hint). Casi opaco pero deja respirar.
191    pub const GLASS_PANEL: u8 = 232;
192
193    /// Elementos deshabilitados — visibles pero con menos peso.
194    pub const DISABLED: u8 = 140;
195
196    /// Hint sutil (text watermark, ghost) — apenas legible.
197    pub const HINT: u8 = 96;
198}
199
200/// Radios de esquina canónicos. La elegancia se construye en escalera:
201/// `XS` para chips e inputs, `SM` para botones, `MD` para paneles,
202/// `LG` para superficies grandes (toast, modal, card destacada).
203pub mod radius {
204    pub const XS: f64 = 2.0;
205    pub const SM: f64 = 4.0;
206    pub const MD: f64 = 8.0;
207    pub const LG: f64 = 12.0;
208    pub const XL: f64 = 20.0;
209}
210
211/// Paleta de la app. Slots semánticos que cubren los casos comunes
212/// (fondo, texto, hover, foco, acento). Los widgets reusables toman su
213/// `Palette` específico desde aquí vía `Palette::from_theme(&theme)`.
214#[derive(Debug, Clone, Copy)]
215pub struct Theme {
216    /// Nombre legible del preset — alimenta `Theme::by_name`,
217    /// `next_after`, y los UIs que ciclan presets (theme-switcher).
218    pub name: &'static str,
219
220    // --- Fondos ---
221    /// Fondo de la ventana / superficie raíz.
222    pub bg_app: Color,
223    /// Fondo de paneles (sidebars, cards).
224    pub bg_panel: Color,
225    /// Fondo alternativo para barras / strips (tab bar, status bar).
226    pub bg_panel_alt: Color,
227    /// Fondo de campos de input (texto editable).
228    pub bg_input: Color,
229    /// Fondo de input cuando tiene foco.
230    pub bg_input_focus: Color,
231    /// Fondo de botón (chip).
232    pub bg_button: Color,
233    /// Fondo de botón al hover.
234    pub bg_button_hover: Color,
235    /// Fondo de la fila/item seleccionado (lista, tree).
236    pub bg_selected: Color,
237    /// Fondo de fila al hover (sin selección).
238    pub bg_row_hover: Color,
239
240    // --- Foregrounds (texto) ---
241    pub fg_text: Color,
242    pub fg_muted: Color,
243    pub fg_placeholder: Color,
244    pub fg_destructive: Color,
245
246    // --- Bordes y acento ---
247    pub border: Color,
248    pub border_focus: Color,
249    /// Acento primario — divisores activos, borde de input focado,
250    /// underline del tab activo, etc. Tono único de la app.
251    pub accent: Color,
252}
253
254impl Default for Theme {
255    fn default() -> Self {
256        Self::dark()
257    }
258}
259
260impl Theme {
261    /// Tema oscuro — el default. Análogo al `nahual-theme` dark en su
262    /// versión Llimphi: tonos azulados profundos, acento azul claro.
263    pub const fn dark() -> Self {
264        Self {
265            name: "Dark",
266            bg_app: Color::from_rgba8(14, 16, 22, 255),
267            bg_panel: Color::from_rgba8(22, 26, 36, 255),
268            bg_panel_alt: Color::from_rgba8(18, 22, 30, 255),
269            bg_input: Color::from_rgba8(16, 20, 28, 255),
270            bg_input_focus: Color::from_rgba8(20, 26, 38, 255),
271            bg_button: Color::from_rgba8(36, 42, 56, 255),
272            bg_button_hover: Color::from_rgba8(54, 64, 86, 255),
273            bg_selected: Color::from_rgba8(58, 78, 128, 255),
274            bg_row_hover: Color::from_rgba8(36, 44, 60, 255),
275            fg_text: Color::from_rgba8(214, 222, 232, 255),
276            fg_muted: Color::from_rgba8(140, 152, 170, 255),
277            fg_placeholder: Color::from_rgba8(95, 105, 122, 255),
278            fg_destructive: Color::from_rgba8(220, 110, 110, 255),
279            border: Color::from_rgba8(46, 54, 70, 255),
280            border_focus: Color::from_rgba8(110, 140, 220, 255),
281            accent: Color::from_rgba8(110, 140, 220, 255),
282        }
283    }
284
285    /// Tema **"Tawa"** — el LOOK FIRMA de la suite, el que se publica en
286    /// screenshots. Decisiones de paleta:
287    ///
288    /// - **Base negro cálido, no azul marino.** A diferencia de `dark()` (un
289    ///   navy genérico, R<B) aquí el fondo es un casi-negro con temperatura:
290    ///   los canales rojo/verde van un punto por encima del azul, así el grafito
291    ///   "respira" tibio en vez de frío. Jerarquía de superficies en escalera
292    ///   suave: `bg_app` (más profundo) → `bg_panel`/`bg_input` → barras → chips,
293    ///   sin saltos bruscos.
294    /// - **Acento teal-eléctrico (#2BD9A6), NO azul.** El mar de unixporn es
295    ///   todo azul (Catppuccin/Tokyo Night); elegimos un verde-aguamarina
296    ///   vibrante para destacar de inmediato — distintivo pero no chillón, con
297    ///   raíz en el teal del CDE / del logo de la suite. `accent` y
298    ///   `border_focus` comparten ese tono; la selección lo usa atenuado para
299    ///   no encandilar filas enteras.
300    /// - **Texto legible (WCAG AA).** `fg_text` (#E8E6E0, marfil cálido) supera
301    ///   12:1 sobre `bg_app` y ~10:1 sobre `bg_panel`; `fg_muted` ronda 5:1
302    ///   (texto secundario cómodo); `fg_placeholder` queda como hint tenue.
303    /// - **Armonía:** todos los grises llevan el mismo tinte cálido y el acento
304    ///   es el único color saturado — la paleta se lee como una sola pieza.
305    pub const fn tawa() -> Self {
306        Self {
307            name: "Tawa",
308            bg_app: Color::from_rgba8(20, 19, 17, 255), // grafito cálido casi-negro
309            bg_panel: Color::from_rgba8(30, 28, 26, 255),
310            bg_panel_alt: Color::from_rgba8(25, 24, 22, 255),
311            bg_input: Color::from_rgba8(24, 23, 21, 255),
312            bg_input_focus: Color::from_rgba8(32, 30, 28, 255),
313            bg_button: Color::from_rgba8(42, 40, 37, 255),
314            bg_button_hover: Color::from_rgba8(56, 53, 49, 255),
315            bg_selected: Color::from_rgba8(26, 74, 64, 255), // teal hundido (selección)
316            bg_row_hover: Color::from_rgba8(40, 38, 35, 255),
317            fg_text: Color::from_rgba8(232, 230, 224, 255), // marfil cálido
318            fg_muted: Color::from_rgba8(160, 154, 144, 255),
319            fg_placeholder: Color::from_rgba8(112, 107, 99, 255),
320            fg_destructive: Color::from_rgba8(232, 116, 97, 255), // coral (cálido, no rojo puro)
321            border: Color::from_rgba8(54, 51, 47, 255),
322            border_focus: Color::from_rgba8(43, 217, 166, 255), // teal-eléctrico
323            accent: Color::from_rgba8(43, 217, 166, 255),       // #2BD9A6 — la firma
324        }
325    }
326
327    /// Tema claro — contraste revisado para WCAG AA sobre `bg_app`:
328    /// `fg_text` ~12:1, `fg_muted` ~5.4:1 (texto secundario legible),
329    /// `fg_destructive` y `accent` oscurecidos para superar 4.5:1 sobre
330    /// fondos claros. `fg_placeholder` queda deliberadamente tenue
331    /// (hint, no contenido).
332    pub const fn light() -> Self {
333        Self {
334            name: "Light",
335            bg_app: Color::from_rgba8(244, 246, 250, 255),
336            bg_panel: Color::from_rgba8(232, 236, 242, 255),
337            bg_panel_alt: Color::from_rgba8(224, 230, 240, 255),
338            bg_input: Color::from_rgba8(255, 255, 255, 255),
339            bg_input_focus: Color::from_rgba8(250, 252, 255, 255),
340            bg_button: Color::from_rgba8(220, 226, 236, 255),
341            bg_button_hover: Color::from_rgba8(200, 210, 226, 255),
342            bg_selected: Color::from_rgba8(160, 180, 220, 255),
343            bg_row_hover: Color::from_rgba8(214, 222, 236, 255),
344            fg_text: Color::from_rgba8(24, 32, 45, 255),
345            fg_muted: Color::from_rgba8(86, 98, 116, 255),
346            fg_placeholder: Color::from_rgba8(140, 150, 168, 255),
347            fg_destructive: Color::from_rgba8(168, 48, 48, 255),
348            border: Color::from_rgba8(190, 199, 214, 255),
349            border_focus: Color::from_rgba8(48, 92, 196, 255),
350            accent: Color::from_rgba8(48, 92, 196, 255),
351        }
352    }
353
354    /// Tema "Aurora" — verdes nocturnos con acento aqua. Análogo al
355    /// preset del nahual-theme.
356    pub const fn aurora() -> Self {
357        Self {
358            name: "Aurora",
359            bg_app: Color::from_rgba8(8, 18, 22, 255),
360            bg_panel: Color::from_rgba8(14, 28, 34, 255),
361            bg_panel_alt: Color::from_rgba8(12, 24, 30, 255),
362            bg_input: Color::from_rgba8(10, 22, 28, 255),
363            bg_input_focus: Color::from_rgba8(14, 30, 38, 255),
364            bg_button: Color::from_rgba8(20, 44, 52, 255),
365            bg_button_hover: Color::from_rgba8(30, 66, 78, 255),
366            bg_selected: Color::from_rgba8(30, 90, 100, 255),
367            bg_row_hover: Color::from_rgba8(20, 46, 56, 255),
368            fg_text: Color::from_rgba8(214, 232, 232, 255),
369            fg_muted: Color::from_rgba8(130, 168, 168, 255),
370            fg_placeholder: Color::from_rgba8(90, 120, 120, 255),
371            fg_destructive: Color::from_rgba8(220, 110, 110, 255),
372            border: Color::from_rgba8(38, 70, 78, 255),
373            border_focus: Color::from_rgba8(80, 200, 200, 255),
374            accent: Color::from_rgba8(80, 200, 200, 255),
375        }
376    }
377
378    /// Tema "Sunset" — cálidos con acento naranja, sobre base oscura.
379    pub const fn sunset() -> Self {
380        Self {
381            name: "Sunset",
382            bg_app: Color::from_rgba8(22, 14, 14, 255),
383            bg_panel: Color::from_rgba8(34, 22, 22, 255),
384            bg_panel_alt: Color::from_rgba8(28, 18, 18, 255),
385            bg_input: Color::from_rgba8(28, 18, 18, 255),
386            bg_input_focus: Color::from_rgba8(36, 24, 22, 255),
387            bg_button: Color::from_rgba8(54, 34, 28, 255),
388            bg_button_hover: Color::from_rgba8(78, 50, 38, 255),
389            bg_selected: Color::from_rgba8(120, 64, 38, 255),
390            bg_row_hover: Color::from_rgba8(56, 36, 28, 255),
391            fg_text: Color::from_rgba8(238, 220, 200, 255),
392            fg_muted: Color::from_rgba8(174, 142, 120, 255),
393            fg_placeholder: Color::from_rgba8(120, 96, 80, 255),
394            fg_destructive: Color::from_rgba8(220, 100, 100, 255),
395            border: Color::from_rgba8(70, 46, 36, 255),
396            border_focus: Color::from_rgba8(232, 140, 70, 255),
397            accent: Color::from_rgba8(232, 140, 70, 255),
398        }
399    }
400
401    /// Tema "Print" — blanco y negro de alto contraste para impresión.
402    /// Fondo blanco papel, tinta negra, sin grises decorativos: todo lo
403    /// que se imprime tiene que leerse en una fotocopiadora. `fg_muted`
404    /// es un gris medio (3.5:1) reservado a metadatos; el cuerpo va en
405    /// negro puro. Acento y bordes negros — la tinta es una sola.
406    pub const fn print() -> Self {
407        Self {
408            name: "Print",
409            bg_app: Color::from_rgba8(255, 255, 255, 255),
410            bg_panel: Color::from_rgba8(255, 255, 255, 255),
411            bg_panel_alt: Color::from_rgba8(246, 246, 246, 255),
412            bg_input: Color::from_rgba8(255, 255, 255, 255),
413            bg_input_focus: Color::from_rgba8(248, 248, 248, 255),
414            bg_button: Color::from_rgba8(238, 238, 238, 255),
415            bg_button_hover: Color::from_rgba8(224, 224, 224, 255),
416            bg_selected: Color::from_rgba8(220, 220, 220, 255),
417            bg_row_hover: Color::from_rgba8(240, 240, 240, 255),
418            fg_text: Color::from_rgba8(0, 0, 0, 255),
419            fg_muted: Color::from_rgba8(90, 90, 90, 255),
420            fg_placeholder: Color::from_rgba8(140, 140, 140, 255),
421            fg_destructive: Color::from_rgba8(0, 0, 0, 255),
422            border: Color::from_rgba8(0, 0, 0, 255),
423            border_focus: Color::from_rgba8(0, 0, 0, 255),
424            accent: Color::from_rgba8(0, 0, 0, 255),
425        }
426    }
427
428    /// Skin **Windows XP "Luna"** — escritorio azul-gris claro, selección y
429    /// acento en el azul XP (#316AC5), chrome celeste. Para la vista `windows-xp`.
430    pub const fn xp_blue() -> Self {
431        Self {
432            name: "WinXP",
433            bg_app: Color::from_rgba8(236, 240, 249, 255),
434            bg_panel: Color::from_rgba8(214, 223, 247, 255),
435            bg_panel_alt: Color::from_rgba8(60, 100, 190, 255), // franja azul (taskbar)
436            bg_input: Color::from_rgba8(255, 255, 255, 255),
437            bg_input_focus: Color::from_rgba8(248, 250, 255, 255),
438            bg_button: Color::from_rgba8(222, 230, 246, 255),
439            bg_button_hover: Color::from_rgba8(198, 214, 244, 255),
440            bg_selected: Color::from_rgba8(49, 106, 197, 255), // azul de selección XP
441            bg_row_hover: Color::from_rgba8(214, 226, 248, 255),
442            fg_text: Color::from_rgba8(20, 30, 50, 255),
443            fg_muted: Color::from_rgba8(78, 92, 120, 255),
444            fg_placeholder: Color::from_rgba8(130, 142, 168, 255),
445            fg_destructive: Color::from_rgba8(176, 32, 32, 255),
446            border: Color::from_rgba8(122, 152, 206, 255),
447            border_focus: Color::from_rgba8(49, 106, 197, 255),
448            accent: Color::from_rgba8(36, 94, 220, 255), // Luna blue
449        }
450    }
451
452    /// Skin **macOS (Big Sur claro)** — casi blanco, grises sutiles, acento
453    /// azul de sistema (#0A84FF). Para la vista `mac`.
454    pub const fn mac_light() -> Self {
455        Self {
456            name: "macOS",
457            bg_app: Color::from_rgba8(246, 246, 248, 255),
458            bg_panel: Color::from_rgba8(236, 236, 240, 255),
459            bg_panel_alt: Color::from_rgba8(242, 242, 245, 235), // menubar translúcida
460            bg_input: Color::from_rgba8(255, 255, 255, 255),
461            bg_input_focus: Color::from_rgba8(252, 252, 255, 255),
462            bg_button: Color::from_rgba8(228, 228, 233, 255),
463            bg_button_hover: Color::from_rgba8(214, 214, 221, 255),
464            bg_selected: Color::from_rgba8(10, 132, 255, 255),
465            bg_row_hover: Color::from_rgba8(232, 234, 240, 255),
466            fg_text: Color::from_rgba8(28, 28, 32, 255),
467            fg_muted: Color::from_rgba8(110, 110, 120, 255),
468            fg_placeholder: Color::from_rgba8(160, 160, 170, 255),
469            fg_destructive: Color::from_rgba8(215, 58, 50, 255),
470            border: Color::from_rgba8(208, 208, 215, 255),
471            border_focus: Color::from_rgba8(10, 132, 255, 255),
472            accent: Color::from_rgba8(10, 132, 255, 255),
473        }
474    }
475
476    /// Skin **KDE Plasma "Breeze" (claro)** — gris papel (#eff0f1), acento
477    /// azul Breeze (#3daee9). Para la vista `kde`.
478    pub const fn kde_breeze() -> Self {
479        Self {
480            name: "Breeze",
481            bg_app: Color::from_rgba8(239, 240, 241, 255),
482            bg_panel: Color::from_rgba8(252, 252, 252, 255),
483            bg_panel_alt: Color::from_rgba8(49, 54, 59, 255), // panel oscuro Breeze
484            bg_input: Color::from_rgba8(255, 255, 255, 255),
485            bg_input_focus: Color::from_rgba8(248, 252, 254, 255),
486            bg_button: Color::from_rgba8(224, 226, 228, 255),
487            bg_button_hover: Color::from_rgba8(208, 211, 214, 255),
488            bg_selected: Color::from_rgba8(61, 174, 233, 255),
489            bg_row_hover: Color::from_rgba8(227, 229, 231, 255),
490            fg_text: Color::from_rgba8(35, 38, 41, 255),
491            fg_muted: Color::from_rgba8(99, 104, 109, 255),
492            fg_placeholder: Color::from_rgba8(150, 155, 160, 255),
493            fg_destructive: Color::from_rgba8(218, 68, 83, 255),
494            border: Color::from_rgba8(188, 192, 196, 255),
495            border_focus: Color::from_rgba8(61, 174, 233, 255),
496            accent: Color::from_rgba8(61, 174, 233, 255),
497        }
498    }
499
500    /// Skin **Windows 3.1**: gris Motif (#c0c0c0) con barra de título azul
501    /// marino (#000080) y escritorio teal. La era de los biseles. Para la vista
502    /// `windows-3.1`.
503    pub const fn win31() -> Self {
504        Self {
505            name: "Win3.1",
506            bg_app: Color::from_rgba8(0, 128, 128, 255), // escritorio teal clásico
507            bg_panel: Color::from_rgba8(192, 192, 192, 255), // gris ventana
508            bg_panel_alt: Color::from_rgba8(0, 0, 128, 255), // barra de título azul marino
509            bg_input: Color::from_rgba8(255, 255, 255, 255),
510            bg_input_focus: Color::from_rgba8(255, 255, 255, 255),
511            bg_button: Color::from_rgba8(192, 192, 192, 255),
512            bg_button_hover: Color::from_rgba8(208, 208, 208, 255),
513            bg_selected: Color::from_rgba8(0, 0, 128, 255),
514            bg_row_hover: Color::from_rgba8(200, 200, 200, 255),
515            fg_text: Color::from_rgba8(0, 0, 0, 255),
516            fg_muted: Color::from_rgba8(64, 64, 64, 255),
517            fg_placeholder: Color::from_rgba8(112, 112, 112, 255),
518            fg_destructive: Color::from_rgba8(128, 0, 0, 255),
519            border: Color::from_rgba8(128, 128, 128, 255),
520            border_focus: Color::from_rgba8(0, 0, 128, 255),
521            accent: Color::from_rgba8(0, 0, 128, 255), // azul Win3.1
522        }
523    }
524
525    /// Skin **Solaris CDE** (era dorada): gris-beige Motif con acento teal —
526    /// el Common Desktop Environment. Para la vista `solaris`.
527    pub const fn cde() -> Self {
528        Self {
529            name: "CDE",
530            bg_app: Color::from_rgba8(45, 70, 90, 255), // fondo azul-gris CDE
531            bg_panel: Color::from_rgba8(174, 178, 195, 255), // gris-lila Motif
532            bg_panel_alt: Color::from_rgba8(120, 130, 150, 255),
533            bg_input: Color::from_rgba8(220, 222, 230, 255),
534            bg_input_focus: Color::from_rgba8(235, 237, 244, 255),
535            bg_button: Color::from_rgba8(160, 166, 185, 255),
536            bg_button_hover: Color::from_rgba8(176, 182, 200, 255),
537            bg_selected: Color::from_rgba8(90, 130, 130, 255),
538            bg_row_hover: Color::from_rgba8(168, 174, 192, 255),
539            fg_text: Color::from_rgba8(20, 24, 32, 255),
540            fg_muted: Color::from_rgba8(64, 72, 84, 255),
541            fg_placeholder: Color::from_rgba8(100, 108, 120, 255),
542            fg_destructive: Color::from_rgba8(140, 40, 40, 255),
543            border: Color::from_rgba8(108, 116, 134, 255),
544            border_focus: Color::from_rgba8(64, 132, 132, 255),
545            accent: Color::from_rgba8(64, 132, 132, 255), // teal CDE
546        }
547    }
548
549    /// Superficie "hundida" — un escalón más profunda que `bg_app`, para
550    /// áreas de lectura intensa (output de terminal, viewports de log,
551    /// IDE-text) que deben recibir el texto con más contraste que el chrome
552    /// y leerse recesadas respecto del marco. En temas oscuros oscurece
553    /// `bg_app` hacia el negro; en claros lo aleja un paso del blanco. Las
554    /// cards/strips (`bg_panel`, `bg_panel_alt`) quedan flotando por encima.
555    /// Derivada de la paleta — no inventa un color suelto.
556    ///
557    /// **Hereda el alfa de `bg_app`**, y no es un detalle: si el tema es de
558    /// vidrio (un cajón translúcido, un panel sobre wallpaper), la superficie
559    /// hundida es *ese mismo fondo un escalón más hondo*, no una tapa opaca.
560    /// Clavaba `255` a mano, y por ahí se moría la transparencia justo donde
561    /// más superficie hay —el área de lectura, que en el cajón de shuma es casi
562    /// todo—: el tema decía 0.92 y esto devolvía 1.0 sin que nadie lo notara.
563    /// Quien necesite la variante sólida la pide explícita (`.with_alpha(1.0)`),
564    /// como hacen las tarjetas de código, que sí deben leerse sobre color pleno.
565    pub fn sunken(&self) -> Color {
566        let c = self.bg_app.components;
567        // Luminancia relativa aproximada en sRGB (sin linealizar — alcanza
568        // para decidir oscuro/claro).
569        let lum = 0.2126 * c[0] + 0.7152 * c[1] + 0.0722 * c[2];
570        let factor = if lum < 0.5 { 0.5 } else { 0.93 };
571        Color::from_rgba8(
572            a_byte(c[0] * factor),
573            a_byte(c[1] * factor),
574            a_byte(c[2] * factor),
575            a_byte(c[3]),
576        )
577    }
578
579    /// Todos los presets del repo, en el orden canónico de rotación
580    /// (Tawa → Dark → Light → Aurora → Sunset → Tawa…). `tawa()` va **al
581    /// frente**: es el look firma de la suite, el primero que se ve. El
582    /// theme-switcher los consume vía [`Theme::next_after`]. `print()` queda
583    /// fuera de la rotación a propósito — es un modo deliberado (imprimir), no
584    /// un gusto estético que se cicle por accidente.
585    pub fn all() -> Vec<Self> {
586        vec![
587            Self::tawa(),
588            Self::dark(),
589            Self::light(),
590            Self::aurora(),
591            Self::sunset(),
592        ]
593    }
594
595    /// Busca un preset por nombre exacto. Incluye los modos deliberados que
596    /// quedan fuera de la rotación casual (`print` y los skins de vista
597    /// `WinXP`/`macOS`/`Breeze`), para que `Config::theme` los resuelva.
598    pub fn by_name(name: &str) -> Option<Self> {
599        Self::all()
600            .into_iter()
601            .chain([
602                Self::print(),
603                Self::xp_blue(),
604                Self::mac_light(),
605                Self::kde_breeze(),
606                Self::win31(),
607                Self::cde(),
608            ])
609            .find(|t| t.name == name)
610    }
611
612    /// Próximo preset en la rotación de [`Theme::all`]. Si `current` no
613    /// se encuentra, retorna el primero — el switcher nunca se traba.
614    pub fn next_after(current: &str) -> Self {
615        let all = Self::all();
616        let idx = all
617            .iter()
618            .position(|t| t.name == current)
619            .map(|i| (i + 1) % all.len())
620            .unwrap_or(0);
621        all[idx]
622    }
623}
624
625#[cfg(test)]
626mod tests {
627    use super::*;
628
629    #[test]
630    fn presets_have_unique_names() {
631        let all = Theme::all();
632        let mut names: Vec<&str> = all.iter().map(|t| t.name).collect();
633        let n_before = names.len();
634        names.sort();
635        names.dedup();
636        assert_eq!(names.len(), n_before, "nombres duplicados en Theme::all()");
637    }
638
639    #[test]
640    fn by_name_finds_each_preset() {
641        for t in Theme::all() {
642            let by = Theme::by_name(t.name).expect("preset registrado");
643            assert_eq!(by.name, t.name);
644        }
645    }
646
647    #[test]
648    fn by_name_returns_none_for_unknown() {
649        assert!(Theme::by_name("ThisDoesNotExist").is_none());
650    }
651
652    #[test]
653    fn next_after_cycles_through_all_presets() {
654        let all = Theme::all();
655        let mut current = all[0].name;
656        let mut visited = vec![current];
657        for _ in 0..all.len() - 1 {
658            current = Theme::next_after(current).name;
659            visited.push(current);
660        }
661        let names: Vec<&str> = all.iter().map(|t| t.name).collect();
662        assert_eq!(visited, names);
663        // El siguiente debe volver al primero.
664        let wrapped = Theme::next_after(current).name;
665        assert_eq!(wrapped, all[0].name);
666    }
667
668    #[test]
669    fn next_after_unknown_falls_back_to_first() {
670        let n = Theme::next_after("Nope").name;
671        assert_eq!(n, Theme::all()[0].name);
672    }
673
674    #[test]
675    fn dark_is_the_default() {
676        assert_eq!(Theme::default().name, "Dark");
677    }
678
679    /// "Tawa" — el look firma — entra en la rotación y va al frente, y
680    /// `by_name` lo resuelve.
681    #[test]
682    fn tawa_es_el_primero_y_se_resuelve() {
683        let all = Theme::all();
684        assert_eq!(all[0].name, "Tawa", "Tawa debe ir al frente de la rotación");
685        assert_eq!(Theme::by_name("Tawa").expect("registrado").name, "Tawa");
686    }
687
688    /// En temas oscuros la superficie hundida es más oscura que el chrome
689    /// (`bg_app`); en claros, también desciende (se lee recesada). En ambos
690    /// casos difiere de `bg_app` — no es un no-op.
691    #[test]
692    fn sunken_is_deeper_than_bg_app() {
693        let lum = |c: Color| {
694            let k = c.components;
695            0.2126 * k[0] + 0.7152 * k[1] + 0.0722 * k[2]
696        };
697        for t in Theme::all() {
698            assert!(
699                lum(t.sunken()) < lum(t.bg_app),
700                "{}: sunken debe ser más oscura que bg_app",
701                t.name
702            );
703        }
704    }
705}