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