gioser

tawasuyu/brahman

Rust 231TOML 70JSON 14HTML 12905 Sutichaykuna

⎇ 5369c307e411a8c8dd5e1f49bb37a53b98a83123 crates/apps/mirada-portal/src/theme_facts.rs Rust · 199 siq'ikuna · 6.4 KB Ñawpa willay Pi imata llamkarqan ch'uya

feat(mirada): mirada-portal — backend de tema org.freedesktop.appearance 5369c307e · sergio ·

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}