//! `llimphi-theme` — paleta compartida entre apps Llimphi. //! //! Define un set de slots semánticos (`bg_app`, `fg_text`, `accent`, etc.) //! que cada widget mapea a su propio `Palette` específico vía //! `Palette::from_theme(&theme)`. El analógo Llimphi al `nahual-theme` //! GPUI, pero con colores `peniko::Color` y sin macros de Background / //! gradiente — Llimphi pinta colores sólidos por ahora. //! //! Disponer del Theme en un crate aparte permite: //! 1. **Consistencia visual**: las apps comparten paleta sin redefinirla. //! 2. **Temas intercambiables**: `Theme::dark()` vs `Theme::light()` (o //! más adelante, sobreescritos por config del usuario). //! 3. **Widgets desacoplados**: cada widget acepta su `Palette` (no el //! Theme entero), así un consumidor que sólo necesita un botón con //! colores no-temáticos puede construir su `ButtonPalette` a mano. #![forbid(unsafe_code)] // `no_std` salvo bajo test (el arnés de pruebas necesita std). La paleta es // datos y aritmética: no había una sola razón para atarla a un sistema // operativo, sólo la inercia de heredar `peniko` a través de `llimphi-raster`. #![cfg_attr(not(test), no_std)] extern crate alloc; use alloc::vec; use alloc::vec::Vec; pub use peniko::Color; /// Un canal de color en 0,0..=1,0 llevado a byte, redondeando al más cercano. /// /// No usa `f32::round`: ese método lo aporta `std`, y este crate es `no_std`. /// La alternativa obvia —`kurbo::common::FloatFuncs`— es una trampa de `cfg`: /// kurbo sólo la define cuando SU feature `std` está apagada, y en el build de /// Linux vello se la enciende, así que el import compilaría en wawa y fallaría /// en Linux. Sumar 0,5 y truncar da el mismo resultado que `round()` para /// valores no negativos, que es todo lo que un canal de color puede ser. fn a_byte(canal: f32) -> u8 { (canal * 255.0 + 0.5).clamp(0.0, 255.0) as u8 } // `core::time::Duration` es EL MISMO tipo que `std::time::Duration` — std sólo // lo reexporta. El cambio no toca a ningún consumidor. use core::time::Duration; // ===================================================================== // Color estable por semilla — avatares, etiquetas, hash-coloring // ===================================================================== /// Paleta sobria de 8 tonos para colorear entidades por hash (avatares de /// contactos, etiquetas de calendario…). Tonos apagados que conviven con /// cualquier `Theme`. Usada vía [`stable_color`]. pub const ENTITY_PALETTE: [(u8, u8, u8); 8] = [ (94, 129, 172), // azul acero (163, 109, 156), // malva (122, 162, 110), // verde salvia (191, 138, 92), // terracota (108, 153, 168), // celeste apagado (170, 120, 120), // rosa viejo (130, 140, 175), // lavanda (150, 150, 110), // oliva ]; /// Color estable derivado de una semilla: hash FNV-1a del texto → índice en /// [`ENTITY_PALETTE`]. La misma semilla da siempre el mismo color, sin estado. /// Para avatares (por correo), etiquetas, badges de entidad, etc. pub fn stable_color(seed: &str) -> Color { let mut h: u32 = 2_166_136_261; for b in seed.bytes() { h ^= b as u32; h = h.wrapping_mul(16_777_619); } let (r, g, b) = ENTITY_PALETTE[(h as usize) % ENTITY_PALETTE.len()]; Color::from_rgba8(r, g, b, 255) } // ===================================================================== // Tokens transversales — motion, alpha, radius // ===================================================================== // // Los widgets de elegancia (tooltip, toast, modal, spinner, splash, …) // comparten **duraciones**, **alphas** y **radios** para que el sistema // se sienta uno solo. Cada token es `const`: las apps pueden referenciar // `motion::NORMAL`/`alpha::SCRIM` directamente, o tomarlos del `Theme` // vía `theme.motion()` / `theme.alpha()` / `theme.radius()` cuando una // future variante por preset lo requiera. /// Duraciones canónicas (segundo nivel: rítmico, no nervioso, no /// soporífero). Los widgets eligen `MICRO` para tintes de hover/focus /// que sólo necesitan suavizar el "salto", `FAST` para microinteracciones /// completas (chip que pulsa), `NORMAL` para transiciones principales /// (toast entrar, modal abrir), `SLOW` para énfasis o entradas dramáticas /// (splash de boot, hero shared-element). pub mod motion { use super::Duration; /// Tintes hover/focus — apenas perceptible, sólo elimina el "clack". pub const MICRO: Duration = Duration::from_millis(50); pub const FAST: Duration = Duration::from_millis(80); pub const NORMAL: Duration = Duration::from_millis(160); pub const SLOW: Duration = Duration::from_millis(320); /// Entradas dramáticas (splash, hero shared-element). pub const DRAMATIC: Duration = Duration::from_millis(480); /// Easing estándar — cubic-out. Energía inicial, asentamiento suave. /// La gran mayoría de transiciones de salida / aparición. #[inline] pub fn ease_out_cubic(t: f32) -> f32 { let inv = 1.0 - t.clamp(0.0, 1.0); 1.0 - inv * inv * inv } /// Easing énfasis — cubic-in-out. Para movimientos que cruzan la /// pantalla y necesitan acentuar el centro (modales, splashes). #[inline] pub fn ease_in_out_cubic(t: f32) -> f32 { let t = t.clamp(0.0, 1.0); if t < 0.5 { 4.0 * t * t * t } else { let f = -2.0 * t + 2.0; 1.0 - f * f * f / 2.0 } } /// Easing fuerte — quint-out. Arranca más rápido que cubic-out y /// asienta más suave. Para elementos que aparecen "lanzados" (toast, /// FAB). #[inline] pub fn ease_out_quint(t: f32) -> f32 { let inv = 1.0 - t.clamp(0.0, 1.0); 1.0 - inv * inv * inv * inv * inv } /// Overshoot suave — back-out con `c1=1.70158` (Material/Penner /// estándar). El valor pasa de 0 al objetivo, lo sobrepasa ~10 % y /// vuelve. Para entradas que necesitan "ping" (modal, snackbar, /// elemento nuevo en una lista). No usar para hover — la oscilación /// se percibe nerviosa. #[inline] pub fn ease_out_back(t: f32) -> f32 { let t = t.clamp(0.0, 1.0); const C1: f32 = 1.701_58; const C3: f32 = C1 + 1.0; let u = t - 1.0; 1.0 + C3 * u * u * u + C1 * u * u } /// Lineal — no es elegante pero a veces es lo correcto (barra de /// progreso, valores numéricos crudos). #[inline] pub fn linear(t: f32) -> f32 { t.clamp(0.0, 1.0) } } /// Tokens de **elevación** — sombras escalonadas. Como `Shadow` vive en /// `llimphi-compositor` (y `llimphi-theme` no depende de él para /// quedarse leaf), cada nivel se expone como `(alpha_u8, blur_px, /// dy_px)`. Los widgets construyen su `Shadow` puenteándolo: /// `Shadow { color: Color::from_rgba8(0,0,0, a), blur, dy, dx: 0.0, spread: 0.0 }`. /// Escala perceptual logarítmica: cada nivel ~×2 de blur. pub mod elevation { /// `(alpha 0–255, blur px, dy px)`. dy ≈ blur·0.4 (sombra natural, /// fuente de luz un poco arriba). pub type Elev = (u8, f64, f64); /// E1 — chip levantado del fondo (hover button, badge). pub const E1: Elev = (44, 4.0, 1.5); /// E2 — card/tile flotante sobre el panel (default cards). pub const E2: Elev = (60, 10.0, 4.0); /// E3 — superficie destacada (menú contextual, dropdown). pub const E3: Elev = (84, 18.0, 8.0); /// E4 — overlay sobre la app (modal, dialog). pub const E4: Elev = (110, 32.0, 14.0); /// E5 — sello de identidad (FAB, hero, picker activo). pub const E5: Elev = (140, 48.0, 22.0); } /// Valores de opacidad alfa (0–255) para capas semánticas. Usar siempre /// que se quiera *transparencia coherente*. El widget que improvisa su /// propio alpha rompe la firma visual. pub mod alpha { /// Scrim que cubre la app cuando hay overlay (menú/modal/picker). /// Apaga el fondo lo justo para que el overlay tenga jerarquía, /// sin ocultar contexto. pub const SCRIM: u8 = 64; /// Tinte aplicado a un panel "vidrio" sobre fondo activo (tooltip, /// status hint). Casi opaco pero deja respirar. pub const GLASS_PANEL: u8 = 232; /// Elementos deshabilitados — visibles pero con menos peso. pub const DISABLED: u8 = 140; /// Hint sutil (text watermark, ghost) — apenas legible. pub const HINT: u8 = 96; } /// Radios de esquina canónicos. La elegancia se construye en escalera: /// `XS` para chips e inputs, `SM` para botones, `MD` para paneles, /// `LG` para superficies grandes (toast, modal, card destacada). pub mod radius { pub const XS: f64 = 2.0; pub const SM: f64 = 4.0; pub const MD: f64 = 8.0; pub const LG: f64 = 12.0; pub const XL: f64 = 20.0; } /// Paleta de la app. Slots semánticos que cubren los casos comunes /// (fondo, texto, hover, foco, acento). Los widgets reusables toman su /// `Palette` específico desde aquí vía `Palette::from_theme(&theme)`. #[derive(Debug, Clone, Copy)] pub struct Theme { /// Nombre legible del preset — alimenta `Theme::by_name`, /// `next_after`, y los UIs que ciclan presets (theme-switcher). pub name: &'static str, // --- Fondos --- /// Fondo de la ventana / superficie raíz. pub bg_app: Color, /// Fondo de paneles (sidebars, cards). pub bg_panel: Color, /// Fondo alternativo para barras / strips (tab bar, status bar). pub bg_panel_alt: Color, /// Fondo de campos de input (texto editable). pub bg_input: Color, /// Fondo de input cuando tiene foco. pub bg_input_focus: Color, /// Fondo de botón (chip). pub bg_button: Color, /// Fondo de botón al hover. pub bg_button_hover: Color, /// Fondo de la fila/item seleccionado (lista, tree). pub bg_selected: Color, /// Fondo de fila al hover (sin selección). pub bg_row_hover: Color, // --- Foregrounds (texto) --- pub fg_text: Color, pub fg_muted: Color, pub fg_placeholder: Color, pub fg_destructive: Color, // --- Bordes y acento --- pub border: Color, pub border_focus: Color, /// Acento primario — divisores activos, borde de input focado, /// underline del tab activo, etc. Tono único de la app. pub accent: Color, } /// Pasos en que se parte una vuelta completa de [`Theme::girado`]: 720, o sea /// **medio grado por paso**. Es la unidad en la que el bus cuenta la deriva /// (`wawa_config::paso_de_deriva`), y por lo tanto **la cadencia con que las /// apps repintan**: una vuelta de 24 h da un repintado cada dos minutos. /// /// El número sale de medir, no de elegir: con medio grado el canal que más se /// mueve salta 3 de 255 (invisible), y con un grado ya salta 4. pub const PASOS_DERIVA: u32 = 720; /// Un canal sRGB (0..1) a luz lineal. Es la curva de verdad, no el `x²` de /// bolsillo: la luminancia que sale de acá es la que decide si el contraste /// sobrevive el giro, y equivocarla por un 10 % es equivocar un 4,5:1 por un /// 4,0:1 — la diferencia entre pasar AA y no. fn a_lineal(canal: f32) -> f32 { if canal <= 0.040_45 { canal / 12.92 } else { libm::powf((canal + 0.055) / 1.055, 2.4) } } /// Luminancia relativa (WCAG/Rec. 709) de un color sRGB. fn luminancia(c: Color) -> f32 { let k = c.components; 0.2126 * a_lineal(k[0]) + 0.7152 * a_lineal(k[1]) + 0.0722 * a_lineal(k[2]) } /// sRGB → HSV. `h` en grados, `s`/`v` en 0..1. fn a_hsv(c: Color) -> (f32, f32, f32) { let k = c.components; let (r, g, b) = (k[0], k[1], k[2]); let max = r.max(g).max(b); let min = r.min(g).min(b); let d = max - min; let s = if max <= 0.0 { 0.0 } else { d / max }; if d <= 0.0 { return (0.0, s, max); } let h = if max == r { 60.0 * (((g - b) / d) % 6.0) } else if max == g { 60.0 * ((b - r) / d + 2.0) } else { 60.0 * ((r - g) / d + 4.0) }; (if h < 0.0 { h + 360.0 } else { h }, s, max) } /// HSV → sRGB, conservando el alfa que se le pase. fn de_hsv(h: f32, s: f32, v: f32, alfa: f32) -> Color { let h = { let x = h % 360.0; if x < 0.0 { x + 360.0 } else { x } }; let c = v * s; let x = c * (1.0 - libm::fabsf((h / 60.0) % 2.0 - 1.0)); let m = v - c; let (r, g, b) = match (h / 60.0) as u32 { 0 => (c, x, 0.0), 1 => (x, c, 0.0), 2 => (0.0, c, x), 3 => (0.0, x, c), 4 => (x, 0.0, c), _ => (c, 0.0, x), }; Color::new([r + m, g + m, b + m, alfa]) } // ── El acento-gradiente vigente del proceso ───────────────────────────────── /// Cuántas paradas guarda el acento-gradiente vigente. pub const ACENTO_PARADAS: usize = 5; static ACENTO_ENCENDIDO: core::sync::atomic::AtomicBool = core::sync::atomic::AtomicBool::new(false); static ACENTO_PARADA: [core::sync::atomic::AtomicU32; ACENTO_PARADAS] = [ core::sync::atomic::AtomicU32::new(0), core::sync::atomic::AtomicU32::new(0), core::sync::atomic::AtomicU32::new(0), core::sync::atomic::AtomicU32::new(0), core::sync::atomic::AtomicU32::new(0), ]; /// **Publica el acento-gradiente del proceso** (`None` = el acento es un /// color). Lo llama quien arma el tema —`wawa-config-llimphi::theme_from_wawa` /// en cada app, y pata encima si su perfil lo apaga— y lo leen los widgets en /// su `from_theme` (pestañas, rail), así una app no tiene que cablearlo a mano /// (2026-10-12: *«los tabs están ocre sin gradiente»* — sólo pata lo sabía). /// /// Vive acá y no en el `Theme` porque `Theme` es `Copy` y lo construyen como /// constante cientos de sitios. En atómicos porque este crate es `no_std`: una /// lectura a mitad de una escritura da, a lo sumo, una parada de un paso antes. pub fn publicar_acento_gradiente(paradas: Option<&[Color]>) { use core::sync::atomic::Ordering; match paradas { Some(p) if p.len() == ACENTO_PARADAS => { for (k, c) in p.iter().enumerate() { let x = c.to_rgba8(); let v = u32::from_be_bytes([x.r, x.g, x.b, x.a]); ACENTO_PARADA[k].store(v, Ordering::Relaxed); } ACENTO_ENCENDIDO.store(true, Ordering::Release); } _ => ACENTO_ENCENDIDO.store(false, Ordering::Release), } } /// Las paradas publicadas, o `None` si el acento vigente es un color. pub fn acento_gradiente_vigente() -> Option<[Color; ACENTO_PARADAS]> { use core::sync::atomic::Ordering; if !ACENTO_ENCENDIDO.load(Ordering::Acquire) { return None; } Some(core::array::from_fn(|k| { let [r, g, b, a] = ACENTO_PARADA[k].load(Ordering::Relaxed).to_be_bytes(); Color::from_rgba8(r, g, b, a) })) } // ── Dónde cae la superficie en su pantalla ────────────────────────────────── static TRAMO_ENCENDIDO: core::sync::atomic::AtomicBool = core::sync::atomic::AtomicBool::new(false); static TRAMO_DESDE: core::sync::atomic::AtomicU32 = core::sync::atomic::AtomicU32::new(0); static TRAMO_ANCHO: core::sync::atomic::AtomicU32 = core::sync::atomic::AtomicU32::new(0); /// **Dónde cae la superficie que se está pintando, en su monitor** (2026-10-12, /// *«que cada control u objeto decida qué rango de colores aplicar, dependa de /// su posición en la pantalla»*): `Some((x, ancho))` = la superficie empieza a /// `x` px del borde izquierdo de un monitor de `ancho` px. Con esto el /// acento-gradiente es UNO para toda la pantalla y cada pieza muestra el tramo /// que le cae encima. Lo fija el que pinta varias superficies (pata, una por /// barra) antes de armar la vista de cada una; `None` = no se sabe (una ventana /// común en Wayland no sabe dónde está) y el degradé vuelve a ser por pieza. pub fn fijar_tramo_de_pantalla(tramo: Option<(f32, f32)>) { use core::sync::atomic::Ordering; match tramo { Some((x, ancho)) if ancho > 0.0 => { TRAMO_DESDE.store(x.to_bits(), Ordering::Relaxed); TRAMO_ANCHO.store(ancho.to_bits(), Ordering::Relaxed); TRAMO_ENCENDIDO.store(true, Ordering::Release); } _ => TRAMO_ENCENDIDO.store(false, Ordering::Release), } } /// Las puntas del degradé de pantalla **en px de la superficie**: de la /// izquierda del monitor a su derecha, corridas por donde cae la superficie. /// `None` si no se fijó el tramo. pub fn puntas_en_superficie() -> Option<((f64, f64), (f64, f64))> { use core::sync::atomic::Ordering; if !TRAMO_ENCENDIDO.load(Ordering::Acquire) { return None; } let x = f32::from_bits(TRAMO_DESDE.load(Ordering::Relaxed)) as f64; let ancho = f32::from_bits(TRAMO_ANCHO.load(Ordering::Relaxed)) as f64; Some(((-x, 0.0), (ancho - x, 0.0))) } /// El acento-gradiente vigente mezclado a la mitad sobre `fondo`: el relleno de /// lo ACTIVO (el diente del rail, la pestaña al frente). A la mitad para que el /// texto de encima, pensado para `fondo`, se siga leyendo. pub fn relleno_activo(fondo: Color) -> Option<[Color; ACENTO_PARADAS]> { let mezcla = |a: Color, b: Color| { let (x, y) = (a.components, b.components); Color::new([ (x[0] + y[0]) * 0.5, (x[1] + y[1]) * 0.5, (x[2] + y[2]) * 0.5, x[3], ]) }; acento_gradiente_vigente().map(|g| g.map(|c| mezcla(fondo, c))) } /// Gira el matiz de UN color conservando su luminancia. El corazón de /// [`Theme::girado`]; el porqué de cada paso está en el doc de ese método. /// /// Público desde el 2026-10-12: el acento-gradiente de `wawa-config-llimphi` /// pinta cada parada como **el acento del tema con otro matiz** —misma luz, /// así que en modo oscuro no encandila y lo escrito encima se lee—. pub fn girar_matiz(c: Color, fase_grados: f32) -> Color { let (h, s, _v) = a_hsv(c); // Un gris no tiene matiz que rotar: girarlo sólo le metería ruido de // redondeo, y son justo las ranuras de texto donde eso se ve. if s < 0.02 { return c; } let alfa = c.components[3]; let objetivo = luminancia(c); let h2 = h + fase_grados; // ¿Le alcanza el croma original para llegar a esa luminancia? Si ni con // V = 1 llega, se baja el croma por bisección hasta que llegue. let mut sat = s; if luminancia(de_hsv(h2, s, 1.0, alfa)) < objetivo { let (mut lo, mut hi) = (0.0f32, s); for _ in 0..20 { let medio = (lo + hi) * 0.5; if luminancia(de_hsv(h2, medio, 1.0, alfa)) >= objetivo { lo = medio; } else { hi = medio; } } sat = lo; } // Y ahora el valor que clava la luminancia. La luminancia crece con V de // forma monótona, así que la bisección converge siempre. let (mut lo, mut hi) = (0.0f32, 1.0f32); for _ in 0..20 { let medio = (lo + hi) * 0.5; if luminancia(de_hsv(h2, sat, medio, alfa)) < objetivo { lo = medio; } else { hi = medio; } } de_hsv(h2, sat, (lo + hi) * 0.5, alfa) } impl Default for Theme { fn default() -> Self { Self::dark() } } impl Theme { /// Tema oscuro — el default. Análogo al `nahual-theme` dark en su /// versión Llimphi: tonos azulados profundos, acento azul claro. pub const fn dark() -> Self { Self { name: "Dark", bg_app: Color::from_rgba8(14, 16, 22, 255), bg_panel: Color::from_rgba8(22, 26, 36, 255), bg_panel_alt: Color::from_rgba8(18, 22, 30, 255), bg_input: Color::from_rgba8(16, 20, 28, 255), bg_input_focus: Color::from_rgba8(20, 26, 38, 255), bg_button: Color::from_rgba8(36, 42, 56, 255), bg_button_hover: Color::from_rgba8(54, 64, 86, 255), bg_selected: Color::from_rgba8(58, 78, 128, 255), bg_row_hover: Color::from_rgba8(36, 44, 60, 255), fg_text: Color::from_rgba8(214, 222, 232, 255), fg_muted: Color::from_rgba8(140, 152, 170, 255), fg_placeholder: Color::from_rgba8(95, 105, 122, 255), fg_destructive: Color::from_rgba8(220, 110, 110, 255), border: Color::from_rgba8(46, 54, 70, 255), border_focus: Color::from_rgba8(110, 140, 220, 255), accent: Color::from_rgba8(110, 140, 220, 255), } } /// Tema **"Kuychi"** (*arcoíris*) — el preset **de fábrica** del escritorio /// desde el 2026-09-09, y el primero de la rotación. /// /// ## Dos intentos, y por qué el primero no servía /// /// La primera versión repartió ocho matices —los del degradado cónico de la /// chakana, muestreado cada 45°— entre las dieciséis ranuras, todos a media /// saturación. El operador la rechazó en una línea que vale más que el /// diseño: *«son varios colores pero igual un solo tono»*. Tenía razón, y el /// error es de manual: **si todo lleva un poco de color, nada tiene color**. /// Ocho matices apagados sobre fondos apagados se leen como un filtro /// sepia — la variedad de matiz no se percibe cuando la saturación y el /// brillo son los mismos en todas partes. /// /// La corrección fue *«tendé a los colores primarios y sus gradientes»*, y /// se implementa al revés de lo anterior: /// /// * **El suelo se vuelve casi neutro.** `bg_app`, `bg_panel`, `bg_input` y /// sus focos quedan con croma bajísimo (S ≤ 0,20): dejan de competir. Son /// el papel, no el dibujo. /// * **El color va ENTERO donde significa algo.** Azul primario en el acento /// y el foco, rojo primario en el error, ámbar en el hover, verde en los /// botones. Nada de tonos polvorientos: `accent` y `fg_destructive` van a /// S ≥ 0,68 con V = 1. /// * **Y sus gradientes.** Un primario aparece en más de un escalón de su /// propia rampa: el azul es el acento (#337EFF) y también la selección /// (#1D5DCC, el mismo matiz más hundido); el verde son los dos escalones /// del botón (reposo y hover). Eso es lo que hace que se lea como una /// familia y no como una bolsa de colores. /// /// El contraste sube en vez de bajar: con el suelo neutro, `fg_text` da /// 17,3:1 sobre `bg_app` (antes 13,9) y el acento 4,7:1 sobre `bg_panel`. /// El texto sobre la fila seleccionada —el caso que un azul primario suele /// romper— da 5,4:1. pub const fn kuychi() -> Self { Self { name: "Kuychi", bg_app: Color::from_rgba8(13, 14, 15, 255), // el papel: casi neutro bg_panel: Color::from_rgba8(23, 24, 27, 255), bg_panel_alt: Color::from_rgba8(20, 17, 22, 255), // barra, tinte violeta bg_input: Color::from_rgba8(10, 11, 12, 255), bg_input_focus: Color::from_rgba8(18, 20, 23, 255), bg_button: Color::from_rgba8(21, 47, 35, 255), // verde, escalón hondo bg_button_hover: Color::from_rgba8(28, 68, 49, 255), // verde, un escalón arriba bg_row_hover: Color::from_rgba8(55, 46, 8, 255), // ámbar, escalón hondo bg_selected: Color::from_rgba8(29, 93, 204, 255), // AZUL primario hundido fg_text: Color::from_rgba8(245, 242, 233, 255), // marfil fg_muted: Color::from_rgba8(156, 162, 173, 255), // gris apenas azulado fg_placeholder: Color::from_rgba8(103, 108, 117, 255), fg_destructive: Color::from_rgba8(255, 82, 87, 255), // ROJO primario border: Color::from_rgba8(66, 54, 71, 255), border_focus: Color::from_rgba8(51, 126, 255, 255), // AZUL primario accent: Color::from_rgba8(51, 126, 255, 255), } } /// **"Kuychi Light"** — el mismo reparto con la luz dada vuelta: papel /// cálido, y los primarios en el escalón que a un fondo claro le sirve. /// /// Dos correcciones que el modo claro obliga y el oscuro no: /// /// * **El acento y el error se hunden.** El azul #337EFF sobre papel da /// 1,9:1 — ilegible. Van a #0A53D1 y #C70A10, que dan 6,0:1 y 5,5:1 sobre /// el panel. El matiz es el mismo; lo que cambia es el escalón. /// * **La selección sube en vez de bajar.** No hay ranura para «el texto /// sobre lo seleccionado»: los widgets pintan `fg_text` encima. Con un /// azul primario de fondo, el texto oscuro da 3,9:1 y no pasa; con el /// escalón claro del MISMO azul (#99BEFF) da 9,4:1. Es la misma rampa, /// leída desde el otro extremo. pub const fn kuychi_light() -> Self { Self { name: "Kuychi Light", bg_app: Color::from_rgba8(255, 253, 247, 255), // papel cálido bg_panel: Color::from_rgba8(240, 244, 251, 255), bg_panel_alt: Color::from_rgba8(240, 228, 245, 255), bg_input: Color::from_rgba8(255, 255, 255, 255), bg_input_focus: Color::from_rgba8(247, 250, 255, 255), bg_button: Color::from_rgba8(183, 237, 212, 255), // verde menta bg_button_hover: Color::from_rgba8(147, 219, 186, 255), bg_row_hover: Color::from_rgba8(252, 236, 169, 255), // ÁMBAR bg_selected: Color::from_rgba8(153, 190, 255, 255), // azul, escalón claro fg_text: Color::from_rgba8(23, 25, 28, 255), fg_muted: Color::from_rgba8(87, 92, 102, 255), fg_placeholder: Color::from_rgba8(133, 140, 153, 255), fg_destructive: Color::from_rgba8(199, 10, 16, 255), // ROJO primario hundido border: Color::from_rgba8(200, 178, 209, 255), border_focus: Color::from_rgba8(10, 83, 209, 255), // AZUL primario hundido accent: Color::from_rgba8(10, 83, 209, 255), } } /// Tema **"Tawa"** — el LOOK FIRMA de la suite, el que se publica en /// screenshots. Decisiones de paleta: /// /// - **Base negro cálido, no azul marino.** A diferencia de `dark()` (un /// navy genérico, R Self { Self { name: "Tawa", bg_app: Color::from_rgba8(20, 19, 17, 255), // grafito cálido casi-negro bg_panel: Color::from_rgba8(30, 28, 26, 255), bg_panel_alt: Color::from_rgba8(25, 24, 22, 255), bg_input: Color::from_rgba8(24, 23, 21, 255), bg_input_focus: Color::from_rgba8(32, 30, 28, 255), bg_button: Color::from_rgba8(42, 40, 37, 255), bg_button_hover: Color::from_rgba8(56, 53, 49, 255), bg_selected: Color::from_rgba8(26, 74, 64, 255), // teal hundido (selección) bg_row_hover: Color::from_rgba8(40, 38, 35, 255), fg_text: Color::from_rgba8(232, 230, 224, 255), // marfil cálido fg_muted: Color::from_rgba8(160, 154, 144, 255), fg_placeholder: Color::from_rgba8(112, 107, 99, 255), fg_destructive: Color::from_rgba8(232, 116, 97, 255), // coral (cálido, no rojo puro) border: Color::from_rgba8(54, 51, 47, 255), border_focus: Color::from_rgba8(43, 217, 166, 255), // teal-eléctrico accent: Color::from_rgba8(43, 217, 166, 255), // #2BD9A6 — la firma } } /// Tema claro — contraste revisado para WCAG AA sobre `bg_app`: /// `fg_text` ~12:1, `fg_muted` ~5.4:1 (texto secundario legible), /// `fg_destructive` y `accent` oscurecidos para superar 4.5:1 sobre /// fondos claros. `fg_placeholder` queda deliberadamente tenue /// (hint, no contenido). pub const fn light() -> Self { Self { name: "Light", bg_app: Color::from_rgba8(244, 246, 250, 255), bg_panel: Color::from_rgba8(232, 236, 242, 255), bg_panel_alt: Color::from_rgba8(224, 230, 240, 255), bg_input: Color::from_rgba8(255, 255, 255, 255), bg_input_focus: Color::from_rgba8(250, 252, 255, 255), bg_button: Color::from_rgba8(220, 226, 236, 255), bg_button_hover: Color::from_rgba8(200, 210, 226, 255), bg_selected: Color::from_rgba8(160, 180, 220, 255), bg_row_hover: Color::from_rgba8(214, 222, 236, 255), fg_text: Color::from_rgba8(24, 32, 45, 255), fg_muted: Color::from_rgba8(86, 98, 116, 255), fg_placeholder: Color::from_rgba8(140, 150, 168, 255), fg_destructive: Color::from_rgba8(168, 48, 48, 255), border: Color::from_rgba8(190, 199, 214, 255), border_focus: Color::from_rgba8(48, 92, 196, 255), accent: Color::from_rgba8(48, 92, 196, 255), } } /// Tema "Aurora" — verdes nocturnos con acento aqua. Análogo al /// preset del nahual-theme. pub const fn aurora() -> Self { Self { name: "Aurora", bg_app: Color::from_rgba8(8, 18, 22, 255), bg_panel: Color::from_rgba8(14, 28, 34, 255), bg_panel_alt: Color::from_rgba8(12, 24, 30, 255), bg_input: Color::from_rgba8(10, 22, 28, 255), bg_input_focus: Color::from_rgba8(14, 30, 38, 255), bg_button: Color::from_rgba8(20, 44, 52, 255), bg_button_hover: Color::from_rgba8(30, 66, 78, 255), bg_selected: Color::from_rgba8(30, 90, 100, 255), bg_row_hover: Color::from_rgba8(20, 46, 56, 255), fg_text: Color::from_rgba8(214, 232, 232, 255), fg_muted: Color::from_rgba8(130, 168, 168, 255), fg_placeholder: Color::from_rgba8(90, 120, 120, 255), fg_destructive: Color::from_rgba8(220, 110, 110, 255), border: Color::from_rgba8(38, 70, 78, 255), border_focus: Color::from_rgba8(80, 200, 200, 255), accent: Color::from_rgba8(80, 200, 200, 255), } } /// Tema "Sunset" — cálidos con acento naranja, sobre base oscura. pub const fn sunset() -> Self { Self { name: "Sunset", bg_app: Color::from_rgba8(22, 14, 14, 255), bg_panel: Color::from_rgba8(34, 22, 22, 255), bg_panel_alt: Color::from_rgba8(28, 18, 18, 255), bg_input: Color::from_rgba8(28, 18, 18, 255), bg_input_focus: Color::from_rgba8(36, 24, 22, 255), bg_button: Color::from_rgba8(54, 34, 28, 255), bg_button_hover: Color::from_rgba8(78, 50, 38, 255), bg_selected: Color::from_rgba8(120, 64, 38, 255), bg_row_hover: Color::from_rgba8(56, 36, 28, 255), fg_text: Color::from_rgba8(238, 220, 200, 255), fg_muted: Color::from_rgba8(174, 142, 120, 255), fg_placeholder: Color::from_rgba8(120, 96, 80, 255), fg_destructive: Color::from_rgba8(220, 100, 100, 255), border: Color::from_rgba8(70, 46, 36, 255), border_focus: Color::from_rgba8(232, 140, 70, 255), accent: Color::from_rgba8(232, 140, 70, 255), } } /// Tema "Print" — blanco y negro de alto contraste para impresión. /// Fondo blanco papel, tinta negra, sin grises decorativos: todo lo /// que se imprime tiene que leerse en una fotocopiadora. `fg_muted` /// es un gris medio (3.5:1) reservado a metadatos; el cuerpo va en /// negro puro. Acento y bordes negros — la tinta es una sola. pub const fn print() -> Self { Self { name: "Print", bg_app: Color::from_rgba8(255, 255, 255, 255), bg_panel: Color::from_rgba8(255, 255, 255, 255), bg_panel_alt: Color::from_rgba8(246, 246, 246, 255), bg_input: Color::from_rgba8(255, 255, 255, 255), bg_input_focus: Color::from_rgba8(248, 248, 248, 255), bg_button: Color::from_rgba8(238, 238, 238, 255), bg_button_hover: Color::from_rgba8(224, 224, 224, 255), bg_selected: Color::from_rgba8(220, 220, 220, 255), bg_row_hover: Color::from_rgba8(240, 240, 240, 255), fg_text: Color::from_rgba8(0, 0, 0, 255), fg_muted: Color::from_rgba8(90, 90, 90, 255), fg_placeholder: Color::from_rgba8(140, 140, 140, 255), fg_destructive: Color::from_rgba8(0, 0, 0, 255), border: Color::from_rgba8(0, 0, 0, 255), border_focus: Color::from_rgba8(0, 0, 0, 255), accent: Color::from_rgba8(0, 0, 0, 255), } } /// Skin **Windows XP "Luna"** — escritorio azul-gris claro, selección y /// acento en el azul XP (#316AC5), chrome celeste. Para la vista `windows-xp`. pub const fn xp_blue() -> Self { Self { name: "WinXP", bg_app: Color::from_rgba8(236, 240, 249, 255), bg_panel: Color::from_rgba8(214, 223, 247, 255), bg_panel_alt: Color::from_rgba8(60, 100, 190, 255), // franja azul (taskbar) bg_input: Color::from_rgba8(255, 255, 255, 255), bg_input_focus: Color::from_rgba8(248, 250, 255, 255), bg_button: Color::from_rgba8(222, 230, 246, 255), bg_button_hover: Color::from_rgba8(198, 214, 244, 255), bg_selected: Color::from_rgba8(49, 106, 197, 255), // azul de selección XP bg_row_hover: Color::from_rgba8(214, 226, 248, 255), fg_text: Color::from_rgba8(20, 30, 50, 255), fg_muted: Color::from_rgba8(78, 92, 120, 255), fg_placeholder: Color::from_rgba8(130, 142, 168, 255), fg_destructive: Color::from_rgba8(176, 32, 32, 255), border: Color::from_rgba8(122, 152, 206, 255), border_focus: Color::from_rgba8(49, 106, 197, 255), accent: Color::from_rgba8(36, 94, 220, 255), // Luna blue } } /// Skin **macOS (Big Sur claro)** — casi blanco, grises sutiles, acento /// azul de sistema (#0A84FF). Para la vista `mac`. pub const fn mac_light() -> Self { Self { name: "macOS", bg_app: Color::from_rgba8(246, 246, 248, 255), bg_panel: Color::from_rgba8(236, 236, 240, 255), bg_panel_alt: Color::from_rgba8(242, 242, 245, 235), // menubar translúcida bg_input: Color::from_rgba8(255, 255, 255, 255), bg_input_focus: Color::from_rgba8(252, 252, 255, 255), bg_button: Color::from_rgba8(228, 228, 233, 255), bg_button_hover: Color::from_rgba8(214, 214, 221, 255), bg_selected: Color::from_rgba8(10, 132, 255, 255), bg_row_hover: Color::from_rgba8(232, 234, 240, 255), fg_text: Color::from_rgba8(28, 28, 32, 255), fg_muted: Color::from_rgba8(110, 110, 120, 255), fg_placeholder: Color::from_rgba8(160, 160, 170, 255), fg_destructive: Color::from_rgba8(215, 58, 50, 255), border: Color::from_rgba8(208, 208, 215, 255), border_focus: Color::from_rgba8(10, 132, 255, 255), accent: Color::from_rgba8(10, 132, 255, 255), } } /// Skin **KDE Plasma "Breeze" (claro)** — gris papel (#eff0f1), acento /// azul Breeze (#3daee9). Para la vista `kde`. pub const fn kde_breeze() -> Self { Self { name: "Breeze", bg_app: Color::from_rgba8(239, 240, 241, 255), bg_panel: Color::from_rgba8(252, 252, 252, 255), bg_panel_alt: Color::from_rgba8(49, 54, 59, 255), // panel oscuro Breeze bg_input: Color::from_rgba8(255, 255, 255, 255), bg_input_focus: Color::from_rgba8(248, 252, 254, 255), bg_button: Color::from_rgba8(224, 226, 228, 255), bg_button_hover: Color::from_rgba8(208, 211, 214, 255), bg_selected: Color::from_rgba8(61, 174, 233, 255), bg_row_hover: Color::from_rgba8(227, 229, 231, 255), fg_text: Color::from_rgba8(35, 38, 41, 255), fg_muted: Color::from_rgba8(99, 104, 109, 255), fg_placeholder: Color::from_rgba8(150, 155, 160, 255), fg_destructive: Color::from_rgba8(218, 68, 83, 255), border: Color::from_rgba8(188, 192, 196, 255), border_focus: Color::from_rgba8(61, 174, 233, 255), accent: Color::from_rgba8(61, 174, 233, 255), } } /// Skin **Windows 3.1**: gris Motif (#c0c0c0) con barra de título azul /// marino (#000080) y escritorio teal. La era de los biseles. Para la vista /// `windows-3.1`. pub const fn win31() -> Self { Self { name: "Win3.1", bg_app: Color::from_rgba8(0, 128, 128, 255), // escritorio teal clásico bg_panel: Color::from_rgba8(192, 192, 192, 255), // gris ventana bg_panel_alt: Color::from_rgba8(0, 0, 128, 255), // barra de título azul marino bg_input: Color::from_rgba8(255, 255, 255, 255), bg_input_focus: Color::from_rgba8(255, 255, 255, 255), bg_button: Color::from_rgba8(192, 192, 192, 255), bg_button_hover: Color::from_rgba8(208, 208, 208, 255), bg_selected: Color::from_rgba8(0, 0, 128, 255), bg_row_hover: Color::from_rgba8(200, 200, 200, 255), fg_text: Color::from_rgba8(0, 0, 0, 255), fg_muted: Color::from_rgba8(64, 64, 64, 255), fg_placeholder: Color::from_rgba8(112, 112, 112, 255), fg_destructive: Color::from_rgba8(128, 0, 0, 255), border: Color::from_rgba8(128, 128, 128, 255), border_focus: Color::from_rgba8(0, 0, 128, 255), accent: Color::from_rgba8(0, 0, 128, 255), // azul Win3.1 } } /// Skin **Solaris CDE** (era dorada): gris-beige Motif con acento teal — /// el Common Desktop Environment. Para la vista `solaris`. pub const fn cde() -> Self { Self { name: "CDE", bg_app: Color::from_rgba8(45, 70, 90, 255), // fondo azul-gris CDE bg_panel: Color::from_rgba8(174, 178, 195, 255), // gris-lila Motif bg_panel_alt: Color::from_rgba8(120, 130, 150, 255), bg_input: Color::from_rgba8(220, 222, 230, 255), bg_input_focus: Color::from_rgba8(235, 237, 244, 255), bg_button: Color::from_rgba8(160, 166, 185, 255), bg_button_hover: Color::from_rgba8(176, 182, 200, 255), bg_selected: Color::from_rgba8(90, 130, 130, 255), bg_row_hover: Color::from_rgba8(168, 174, 192, 255), fg_text: Color::from_rgba8(20, 24, 32, 255), fg_muted: Color::from_rgba8(64, 72, 84, 255), fg_placeholder: Color::from_rgba8(100, 108, 120, 255), fg_destructive: Color::from_rgba8(140, 40, 40, 255), border: Color::from_rgba8(108, 116, 134, 255), border_focus: Color::from_rgba8(64, 132, 132, 255), accent: Color::from_rgba8(64, 132, 132, 255), // teal CDE } } /// Superficie "hundida" — un escalón más profunda que `bg_app`, para /// áreas de lectura intensa (output de terminal, viewports de log, /// IDE-text) que deben recibir el texto con más contraste que el chrome /// y leerse recesadas respecto del marco. En temas oscuros oscurece /// `bg_app` hacia el negro; en claros lo aleja un paso del blanco. Las /// cards/strips (`bg_panel`, `bg_panel_alt`) quedan flotando por encima. /// Derivada de la paleta — no inventa un color suelto. /// /// **Hereda el alfa de `bg_app`**, y no es un detalle: si el tema es de /// vidrio (un cajón translúcido, un panel sobre wallpaper), la superficie /// hundida es *ese mismo fondo un escalón más hondo*, no una tapa opaca. /// Clavaba `255` a mano, y por ahí se moría la transparencia justo donde /// más superficie hay —el área de lectura, que en el cajón de shuma es casi /// todo—: el tema decía 0.92 y esto devolvía 1.0 sin que nadie lo notara. /// Quien necesite la variante sólida la pide explícita (`.with_alpha(1.0)`), /// como hacen las tarjetas de código, que sí deben leerse sobre color pleno. pub fn sunken(&self) -> Color { let c = self.bg_app.components; // Luminancia relativa aproximada en sRGB (sin linealizar — alcanza // para decidir oscuro/claro). let lum = 0.2126 * c[0] + 0.7152 * c[1] + 0.0722 * c[2]; let factor = if lum < 0.5 { 0.5 } else { 0.93 }; Color::from_rgba8( a_byte(c[0] * factor), a_byte(c[1] * factor), a_byte(c[2] * factor), a_byte(c[3]), ) } /// **La deriva**: el mismo tema con el matiz de todas sus ranuras girado /// `fase_grados`. Es una función pura del ángulo — no lee reloj, no guarda /// estado, y la misma fase da siempre el mismo tema. /// /// ## Qué gira y qué no /// /// Giran las quince ranuras que llevan matiz. **`fg_destructive` no gira**: /// rojo-es-error es una convención, no un matiz cualquiera, y se ve al /// primer intento — en la fase 90° el mensaje de error queda verde. Una /// ranura casi gris (saturación < 2 %) tampoco: no tiene matiz que rotar. /// /// ## Por qué no alcanza con sumarle grados al matiz /// /// A saturación y valor fijos, **el amarillo es cuatro veces más luminoso /// que el azul**. Girar sin más haría que el contraste del tema cambiara /// con la fase: medido sobre la paleta de fábrica, el acento cae a 2,6:1 en /// la fase 22° y el error a 3,4:1 en la 242° — o sea, el escritorio deja de /// ser legible dos veces por vuelta, y a una hora distinta cada día. /// /// Así que cada color se gira **conservando su luminancia**: se busca el /// valor (V) que devuelve la luminancia relativa original en el matiz /// nuevo. Cuando ni con V = 1 alcanza —el azul saturado no puede ser tan /// luminoso como el amarillo: el gamut sRGB no da— se **baja el croma** /// hasta que llegue, que es lo que hace cualquier mapeo a gamut. /// /// Ese descenso de croma va por **bisección, no a saltos**. Bajarlo /// multiplicando por un factor hasta que entre da escalones de 6 % de /// saturación, y en una deriva lenta eso no se ve como deriva: se ve como /// un **tirón**. Medido, el paso de 0,125° saltaba 13/255 en el acento con /// el descenso a saltos y salta **1/255** con la bisección. /// /// ## El paso /// /// Con [`PASOS_DERIVA`] pasos por vuelta (0,5° cada uno) el canal que más /// se mueve salta **3 de 255** — por debajo de lo que un ojo distingue en /// un color de interfaz. Quien mueva ese número lo mide de nuevo: el test /// `la_deriva_no_da_tirones` falla si un paso salta más de 4. pub fn girado(&self, fase_grados: f32) -> Self { let g = |c: Color| girar_matiz(c, fase_grados); Self { name: self.name, bg_app: g(self.bg_app), bg_panel: g(self.bg_panel), bg_panel_alt: g(self.bg_panel_alt), bg_input: g(self.bg_input), bg_input_focus: g(self.bg_input_focus), bg_button: g(self.bg_button), bg_button_hover: g(self.bg_button_hover), bg_selected: g(self.bg_selected), bg_row_hover: g(self.bg_row_hover), fg_text: g(self.fg_text), fg_muted: g(self.fg_muted), fg_placeholder: g(self.fg_placeholder), // El rojo del error se queda donde está. Ver el doc de arriba. fg_destructive: self.fg_destructive, border: g(self.border), border_focus: g(self.border_focus), accent: g(self.accent), } } /// Todos los presets del repo, en el orden canónico de rotación /// (Kuychi → Kuychi Light → Tawa → Dark → Light → Aurora → Sunset → /// Kuychi…). `kuychi()` va **al frente** desde el 2026-09-09: es el preset /// de fábrica del escritorio, y su variante clara va pegada atrás para que /// un solo paso del switcher lleve del día a la noche del **mismo** look. /// `tawa()` queda tercero — sigue siendo el look firma de las capturas. /// El theme-switcher los consume vía [`Theme::next_after`]. `print()` queda /// fuera de la rotación a propósito — es un modo deliberado (imprimir), no /// un gusto estético que se cicle por accidente. pub fn all() -> Vec { vec![ Self::kuychi(), Self::kuychi_light(), Self::tawa(), Self::dark(), Self::light(), Self::aurora(), Self::sunset(), ] } /// Busca un preset por nombre exacto. Incluye los modos deliberados que /// quedan fuera de la rotación casual (`print` y los skins de vista /// `WinXP`/`macOS`/`Breeze`), para que `Config::theme` los resuelva. pub fn by_name(name: &str) -> Option { Self::all() .into_iter() .chain([ Self::print(), Self::xp_blue(), Self::mac_light(), Self::kde_breeze(), Self::win31(), Self::cde(), ]) .find(|t| t.name == name) } /// Próximo preset en la rotación de [`Theme::all`]. Si `current` no /// se encuentra, retorna el primero — el switcher nunca se traba. pub fn next_after(current: &str) -> Self { let all = Self::all(); let idx = all .iter() .position(|t| t.name == current) .map(|i| (i + 1) % all.len()) .unwrap_or(0); all[idx] } } #[cfg(test)] mod tests { use super::*; #[test] fn presets_have_unique_names() { let all = Theme::all(); let mut names: Vec<&str> = all.iter().map(|t| t.name).collect(); let n_before = names.len(); names.sort(); names.dedup(); assert_eq!(names.len(), n_before, "nombres duplicados en Theme::all()"); } #[test] fn by_name_finds_each_preset() { for t in Theme::all() { let by = Theme::by_name(t.name).expect("preset registrado"); assert_eq!(by.name, t.name); } } #[test] fn by_name_returns_none_for_unknown() { assert!(Theme::by_name("ThisDoesNotExist").is_none()); } #[test] fn next_after_cycles_through_all_presets() { let all = Theme::all(); let mut current = all[0].name; let mut visited = vec![current]; for _ in 0..all.len() - 1 { current = Theme::next_after(current).name; visited.push(current); } let names: Vec<&str> = all.iter().map(|t| t.name).collect(); assert_eq!(visited, names); // El siguiente debe volver al primero. let wrapped = Theme::next_after(current).name; assert_eq!(wrapped, all[0].name); } #[test] fn next_after_unknown_falls_back_to_first() { let n = Theme::next_after("Nope").name; assert_eq!(n, Theme::all()[0].name); } #[test] fn dark_is_the_default() { assert_eq!(Theme::default().name, "Dark"); } /// "Kuychi" —el preset de fábrica— va al frente de la rotación, con su /// variante clara pegada atrás; "Tawa" —el look firma— sigue en la rotación /// y `by_name` resuelve a los tres. #[test] fn kuychi_es_el_primero_y_tawa_sigue_en_la_rotacion() { let all = Theme::all(); assert_eq!(all[0].name, "Kuychi", "Kuychi debe ir al frente de la rotación"); assert_eq!(all[1].name, "Kuychi Light", "y su variante clara, pegada atrás"); assert!(all.iter().any(|t| t.name == "Tawa"), "Tawa no se sale de la rotación"); for n in ["Kuychi", "Kuychi Light", "Tawa"] { assert_eq!(Theme::by_name(n).expect("registrado").name, n); } } /// Lo que hace de Kuychi lo que el operador pidió: **ningún matiz manda**. /// Se cuentan los matices de las ranuras que llevan color de verdad (los /// fondos casi-neutros no entran) y tienen que ser cuatro o más, separados /// entre sí. Un preset de un acento solo da uno o dos y falla acá. /// /// El matiz se calcula a mano (HSV) porque el crate es `no_std` y no tiene /// de dónde sacarlo — son ocho líneas y evitan una dependencia. #[test] fn kuychi_no_tiene_un_solo_color_predominante() { fn matiz(c: Color) -> f32 { let k = c.components; let (r, g, b) = (k[0], k[1], k[2]); let max = r.max(g).max(b); let min = r.min(g).min(b); let d = max - min; if d < 1e-6 { return -1.0; } let h = if max == r { 60.0 * (((g - b) / d) % 6.0) } else if max == g { 60.0 * ((b - r) / d + 2.0) } else { 60.0 * ((r - g) / d + 4.0) }; if h < 0.0 { h + 360.0 } else { h } } for t in [Theme::kuychi(), Theme::kuychi_light()] { let matices: Vec = [ t.bg_button, t.bg_row_hover, t.bg_selected, t.fg_muted, t.fg_destructive, t.accent, ] .iter() .map(|c| matiz(*c)) .collect(); // Distintos de a pares: ningún par a menos de 25° (y el círculo da // la vuelta, así que la distancia es la corta). let mut distintos = 0; for (i, a) in matices.iter().enumerate() { let choca = matices.iter().take(i).any(|b| { let d = (a - b).abs(); d.min(360.0 - d) < 25.0 }); if !choca { distintos += 1; } } assert!( distintos >= 4, "{}: sólo {distintos} matices distintos entre estado, acento y error — \ eso es un preset de un color, no Kuychi", t.name ); // Y la lección que costó la primera versión: la variedad de matiz no // se ve si todo lleva el MISMO poco de color. El suelo tiene que ser // casi neutro y el color tiene que ir entero donde significa algo. // Este par de asserts es lo que la v1 no pasaba (su panel iba a // S=0,42) y es la razón de que el operador la viera «de un solo // tono» teniendo ocho matices adentro. let sat = |c: Color| { let k = c.components; let max = k[0].max(k[1]).max(k[2]); let min = k[0].min(k[1]).min(k[2]); if max < 1e-6 { 0.0 } else { (max - min) / max } }; for (nombre, c) in [("bg_app", t.bg_app), ("bg_panel", t.bg_panel), ("bg_input", t.bg_input)] { assert!( sat(c) <= 0.25, "{}: el suelo {nombre} lleva croma {:.2} — si el fondo tiene color, el color no se ve", t.name, sat(c) ); } for (nombre, c) in [("accent", t.accent), ("fg_destructive", t.fg_destructive)] { assert!( sat(c) >= 0.60, "{}: {nombre} lleva croma {:.2} — un primario a media saturación es un tono polvoriento", t.name, sat(c) ); } } } /// La deriva **no da tirones**: un paso de los 720 no mueve ningún canal /// más de 4 de 255. Es el test que atrapó el descenso de croma a saltos /// (multiplicar por 0,94 hasta que entre), que saltaba 13/255 en el acento /// y se veía como un tic, no como una deriva. #[test] fn la_deriva_no_da_tirones() { let paso = 360.0 / PASOS_DERIVA as f32; for base in [Theme::kuychi(), Theme::kuychi_light()] { let mut peor = 0i32; let mut donde = ""; // Una vuelta entera, paso a paso. Son 720 × 16 comparaciones: barato. for i in 0..PASOS_DERIVA { let a = base.girado(i as f32 * paso); let b = base.girado((i + 1) as f32 * paso); for (nombre, x, y) in ranuras(&a).iter().zip(ranuras(&b).iter()).map( |((n, x), (_, y))| (*n, *x, *y), ) { let (x, y) = (x.to_rgba8(), y.to_rgba8()); let d = (x.r as i32 - y.r as i32) .abs() .max((x.g as i32 - y.g as i32).abs()) .max((x.b as i32 - y.b as i32).abs()); if d > peor { peor = d; donde = nombre; } } } assert!( peor <= 4, "{}: un paso de la deriva salta {peor}/255 en {donde} — eso se ve como un tirón", base.name ); } } /// Y **no rompe el contraste en ninguna de las 720 fases**. Sin conservar /// la luminancia esto falla feo: el acento cae a 2,6:1 alrededor de la fase /// 22°. Es la razón de que `girar_matiz` haga bisección en vez de sumarle /// grados al matiz y listo. #[test] fn la_deriva_no_rompe_el_contraste() { let paso = 360.0 / PASOS_DERIVA as f32; let contraste = |a: Color, b: Color| { let (la, lb) = (luminancia(a), luminancia(b)); let (alto, bajo) = if la >= lb { (la, lb) } else { (lb, la) }; (alto + 0.05) / (bajo + 0.05) }; for base in [Theme::kuychi(), Theme::kuychi_light()] { for i in 0..PASOS_DERIVA { let t = base.girado(i as f32 * paso); let pruebas = [ ("texto/fondo", t.fg_text, t.bg_app, 7.0), ("secundario/panel", t.fg_muted, t.bg_panel, 4.5), ("acento/panel", t.accent, t.bg_panel, 4.5), ("error/panel", t.fg_destructive, t.bg_panel, 4.5), ("texto/seleccionada", t.fg_text, t.bg_selected, 4.5), ("texto/hover", t.fg_text, t.bg_row_hover, 4.5), ]; for (que, a, b, minimo) in pruebas { let r = contraste(a, b); assert!( r >= minimo, "{}: en la fase {:.1}° el contraste {que} cae a {r:.2} (mínimo {minimo})", base.name, i as f32 * paso ); } } } } /// El rojo del error **no gira**, y el resto sí. Sin esto, en la fase 90° /// el mensaje de error queda verde — se ve en el primer render. #[test] fn el_rojo_del_error_no_gira_y_el_resto_si() { let base = Theme::kuychi(); let girado = base.girado(90.0); assert_eq!( girado.fg_destructive.to_rgba8().to_u32(), base.fg_destructive.to_rgba8().to_u32(), "el error cambió de color: rojo-es-error no es un matiz cualquiera" ); assert_ne!( girado.accent.to_rgba8().to_u32(), base.accent.to_rgba8().to_u32(), "el acento NO giró: entonces no hay deriva" ); // Y el nombre no cambia: un tema girado sigue siendo el mismo preset, // o `by_name` dejaría de encontrarlo y la config no se podría guardar. assert_eq!(girado.name, base.name); } /// Una vuelta entera vuelve al mismo lugar (±1 de redondeo). Es lo que hace /// que la deriva sea un ciclo y no un paseo: si el error se acumulara, a los /// tres días el escritorio estaría en cualquier parte. #[test] fn una_vuelta_entera_vuelve_al_mismo_lugar() { for base in [Theme::kuychi(), Theme::kuychi_light()] { let vuelta = base.girado(360.0); for ((nombre, a), (_, b)) in ranuras(&base).iter().zip(ranuras(&vuelta).iter()) { let (a, b) = (a.to_rgba8(), b.to_rgba8()); let d = (a.r as i32 - b.r as i32) .abs() .max((a.g as i32 - b.g as i32).abs()) .max((a.b as i32 - b.b as i32).abs()); assert!(d <= 1, "{}: {nombre} no volvió tras una vuelta (Δ{d})", base.name); } } } /// Las dieciséis ranuras con su nombre, para que un test que las recorra /// pueda decir CUÁL falló en vez de «alguna». fn ranuras(t: &Theme) -> [(&'static str, Color); 16] { [ ("bg_app", t.bg_app), ("bg_panel", t.bg_panel), ("bg_panel_alt", t.bg_panel_alt), ("bg_input", t.bg_input), ("bg_input_focus", t.bg_input_focus), ("bg_button", t.bg_button), ("bg_button_hover", t.bg_button_hover), ("bg_selected", t.bg_selected), ("bg_row_hover", t.bg_row_hover), ("fg_text", t.fg_text), ("fg_muted", t.fg_muted), ("fg_placeholder", t.fg_placeholder), ("fg_destructive", t.fg_destructive), ("border", t.border), ("border_focus", t.border_focus), ("accent", t.accent), ] } /// En temas oscuros la superficie hundida es más oscura que el chrome /// (`bg_app`); en claros, también desciende (se lee recesada). En ambos /// casos difiere de `bg_app` — no es un no-op. #[test] fn sunken_is_deeper_than_bg_app() { let lum = |c: Color| { let k = c.components; 0.2126 * k[0] + 0.7152 * k[1] + 0.0722 * k[2] }; for t in Theme::all() { assert!( lum(t.sunken()) < lum(t.bg_app), "{}: sunken debe ser más oscura que bg_app", t.name ); } } }