tawasuyu/llimphi
Sovereign retained-mode GPU UI framework in Rust (wgpu+vello+taffy+parley), Elm loop, 40+ widgets.
chore: refresco desde el monorepo — vello 0.9 / wgpu 29, 120 miembros
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}