⎇ 5369c307e411a8c8dd5e1f49bb37a53b98a83123
crates/apps/mirada-portal/src/theme_facts.rs
Ñawpa willay
Pi imata llamkarqan
ch'uya
feat(mirada): mirada-portal — backend de tema org.freedesktop.appearance
1//! Tabla de hechos del tema relevantes para el portal.
2//!
3//! El portal sólo necesita tres hechos de cada tema: si es oscuro, su
4//! color de acento, y si es de alto contraste. La fuente de verdad de
5//! la paleta completa es `nahual_theme::Theme` (crate `nahual-theme`);
6//! esta tabla la **espeja deliberadamente** para que el daemon del
7//! portal no tenga que enlazar GPUI (que `nahual-theme` arrastra por
8//! sus tipos `Hsla`/`Background`).
9//!
10//! Si se agrega un preset nuevo a `nahual_theme::Theme::all()`, hay que
11//! reflejarlo aquí. Un nombre desconocido cae a [`FALLBACK`] — el
12//! portal degrada a "oscuro, sin acento marcado" en vez de romperse.
13
14/// Hechos de un tema que el portal expone por `org.freedesktop.appearance`.
15#[derive(Clone, Copy, Debug, PartialEq)]
16pub struct ThemeFacts {
17 /// `true` → el tema es oscuro (`color-scheme` = 1).
18 pub is_dark: bool,
19 /// `true` → alto contraste (`contrast` = 1).
20 pub high_contrast: bool,
21 /// Color de acento en HSL: `(matiz 0..360, saturación 0..1, luz 0..1)`.
22 /// Se guarda en HSL porque así está escrito en `nahual-theme` — la
23 /// conversión a RGB se hace al servir el valor.
24 pub accent_hsl: (f64, f64, f64),
25}
26
27impl ThemeFacts {
28 /// `color-scheme` de `org.freedesktop.appearance`: 0 = sin
29 /// preferencia, 1 = oscuro, 2 = claro. El escritorio siempre tiene
30 /// un tema activo, así que nunca devolvemos 0.
31 pub fn color_scheme(&self) -> u32 {
32 if self.is_dark {
33 1
34 } else {
35 2
36 }
37 }
38
39 /// `contrast` de `org.freedesktop.appearance`: 0 = normal,
40 /// 1 = contraste alto.
41 pub fn contrast(&self) -> u32 {
42 u32::from(self.high_contrast)
43 }
44
45 /// Acento como RGB en 0..1, el formato `(ddd)` que pide el portal.
46 pub fn accent_rgb(&self) -> (f64, f64, f64) {
47 let (h, s, l) = self.accent_hsl;
48 hsl_to_rgb(h, s, l)
49 }
50}
51
52/// Tema por defecto si el nombre persistido no se reconoce: oscuro, sin
53/// acento marcado (gris neutro). Degradación segura ante un preset
54/// futuro que esta tabla aún no conozca.
55pub const FALLBACK: ThemeFacts = ThemeFacts {
56 is_dark: true,
57 high_contrast: false,
58 accent_hsl: (0.0, 0.0, 0.5),
59};
60
61/// Mapea el nombre persistido de un tema a sus hechos. Espeja
62/// `nahual_theme::Theme::all()` (8 presets al 2026-05-21). Los números
63/// de acento están copiados literalmente de `nahual-theme/src/lib.rs`.
64pub fn facts_for(name: &str) -> ThemeFacts {
65 match name.trim() {
66 "Nebula" => ThemeFacts {
67 is_dark: true,
68 high_contrast: false,
69 accent_hsl: (280.0, 0.65, 0.65),
70 },
71 "Aurora" => ThemeFacts {
72 is_dark: true,
73 high_contrast: false,
74 accent_hsl: (150.0, 0.70, 0.55),
75 },
76 "Sunset" => ThemeFacts {
77 is_dark: true,
78 high_contrast: false,
79 accent_hsl: (15.0, 0.78, 0.62),
80 },
81 "Flat Dark" => ThemeFacts {
82 is_dark: true,
83 high_contrast: false,
84 accent_hsl: (210.0, 0.70, 0.55),
85 },
86 "Solarized Light" => ThemeFacts {
87 is_dark: false,
88 high_contrast: false,
89 accent_hsl: (205.0, 0.69, 0.42),
90 },
91 "High Contrast" => ThemeFacts {
92 is_dark: true,
93 high_contrast: true,
94 accent_hsl: (60.0, 1.00, 0.60),
95 },
96 "Print Color" => ThemeFacts {
97 is_dark: false,
98 high_contrast: false,
99 accent_hsl: (15.0, 0.70, 0.40),
100 },
101 "Print B&W" => ThemeFacts {
102 is_dark: false,
103 high_contrast: false,
104 accent_hsl: (0.0, 0.00, 0.20),
105 },
106 _ => FALLBACK,
107 }
108}
109
110/// HSL → RGB. `h` en grados 0..360, `s` y `l` en 0..1. Devuelve RGB en
111/// 0..1. Algoritmo estándar (croma + segmento del matiz).
112fn hsl_to_rgb(h: f64, s: f64, l: f64) -> (f64, f64, f64) {
113 let c = (1.0 - (2.0 * l - 1.0).abs()) * s;
114 let h_prime = h.rem_euclid(360.0) / 60.0;
115 let x = c * (1.0 - (h_prime % 2.0 - 1.0).abs());
116 let (r1, g1, b1) = match h_prime as u32 {
117 0 => (c, x, 0.0),
118 1 => (x, c, 0.0),
119 2 => (0.0, c, x),
120 3 => (0.0, x, c),
121 4 => (x, 0.0, c),
122 _ => (c, 0.0, x),
123 };
124 let m = l - c / 2.0;
125 (r1 + m, g1 + m, b1 + m)
126}
127
128#[cfg(test)]
129mod tests {
130 use super::*;
131
132 fn approx(a: f64, b: f64) -> bool {
133 (a - b).abs() < 1e-6
134 }
135
136 fn rgb_eq(got: (f64, f64, f64), want: (f64, f64, f64)) -> bool {
137 approx(got.0, want.0) && approx(got.1, want.1) && approx(got.2, want.2)
138 }
139
140 #[test]
141 fn hsl_primaries() {
142 assert!(rgb_eq(hsl_to_rgb(0.0, 1.0, 0.5), (1.0, 0.0, 0.0)));
143 assert!(rgb_eq(hsl_to_rgb(120.0, 1.0, 0.5), (0.0, 1.0, 0.0)));
144 assert!(rgb_eq(hsl_to_rgb(240.0, 1.0, 0.5), (0.0, 0.0, 1.0)));
145 }
146
147 #[test]
148 fn hsl_grays() {
149 assert!(rgb_eq(hsl_to_rgb(0.0, 0.0, 0.0), (0.0, 0.0, 0.0)));
150 assert!(rgb_eq(hsl_to_rgb(0.0, 0.0, 1.0), (1.0, 1.0, 1.0)));
151 // Acento de "Print B&W": gris medio-oscuro.
152 assert!(rgb_eq(hsl_to_rgb(0.0, 0.0, 0.2), (0.2, 0.2, 0.2)));
153 }
154
155 #[test]
156 fn known_themes_map_color_scheme() {
157 assert_eq!(facts_for("Nebula").color_scheme(), 1);
158 assert_eq!(facts_for("Aurora").color_scheme(), 1);
159 assert_eq!(facts_for("Solarized Light").color_scheme(), 2);
160 assert_eq!(facts_for("Print Color").color_scheme(), 2);
161 }
162
163 #[test]
164 fn high_contrast_only_for_high_contrast_theme() {
165 assert!(facts_for("High Contrast").high_contrast);
166 assert_eq!(facts_for("High Contrast").contrast(), 1);
167 assert!(!facts_for("Nebula").high_contrast);
168 assert_eq!(facts_for("Nebula").contrast(), 0);
169 }
170
171 #[test]
172 fn unknown_theme_falls_back() {
173 let f = facts_for("NoSuchTheme");
174 assert_eq!(f, FALLBACK);
175 assert_eq!(f.color_scheme(), 1, "FALLBACK es oscuro");
176 }
177
178 #[test]
179 fn accent_rgb_in_range() {
180 for name in [
181 "Nebula",
182 "Aurora",
183 "Sunset",
184 "Flat Dark",
185 "Solarized Light",
186 "High Contrast",
187 "Print Color",
188 "Print B&W",
189 ] {
190 let (r, g, b) = facts_for(name).accent_rgb();
191 for ch in [r, g, b] {
192 assert!(
193 (0.0..=1.0).contains(&ch),
194 "{name}: canal fuera de rango: {ch}"
195 );
196 }
197 }
198 }
199}