5369c307e4
Backend de xdg-desktop-portal para carmen: implementa org.freedesktop.impl.portal.Settings y publica color-scheme, accent-color y contrast desde el tema activo de nahual. GTK4, Qt6, Firefox y Chromium voltean claro/oscuro + acento por protocolo, sin tocar sus configs. Watcher con notify del archivo de nahual-theme → emite SettingChanged en vivo. 13 tests; smoke verificado sobre un bus de sesión efímero. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
200 lines
6.4 KiB
Rust
200 lines
6.4 KiB
Rust
//! Tabla de hechos del tema relevantes para el portal.
|
|
//!
|
|
//! El portal sólo necesita tres hechos de cada tema: si es oscuro, su
|
|
//! color de acento, y si es de alto contraste. La fuente de verdad de
|
|
//! la paleta completa es `nahual_theme::Theme` (crate `nahual-theme`);
|
|
//! esta tabla la **espeja deliberadamente** para que el daemon del
|
|
//! portal no tenga que enlazar GPUI (que `nahual-theme` arrastra por
|
|
//! sus tipos `Hsla`/`Background`).
|
|
//!
|
|
//! Si se agrega un preset nuevo a `nahual_theme::Theme::all()`, hay que
|
|
//! reflejarlo aquí. Un nombre desconocido cae a [`FALLBACK`] — el
|
|
//! portal degrada a "oscuro, sin acento marcado" en vez de romperse.
|
|
|
|
/// Hechos de un tema que el portal expone por `org.freedesktop.appearance`.
|
|
#[derive(Clone, Copy, Debug, PartialEq)]
|
|
pub struct ThemeFacts {
|
|
/// `true` → el tema es oscuro (`color-scheme` = 1).
|
|
pub is_dark: bool,
|
|
/// `true` → alto contraste (`contrast` = 1).
|
|
pub high_contrast: bool,
|
|
/// Color de acento en HSL: `(matiz 0..360, saturación 0..1, luz 0..1)`.
|
|
/// Se guarda en HSL porque así está escrito en `nahual-theme` — la
|
|
/// conversión a RGB se hace al servir el valor.
|
|
pub accent_hsl: (f64, f64, f64),
|
|
}
|
|
|
|
impl ThemeFacts {
|
|
/// `color-scheme` de `org.freedesktop.appearance`: 0 = sin
|
|
/// preferencia, 1 = oscuro, 2 = claro. El escritorio siempre tiene
|
|
/// un tema activo, así que nunca devolvemos 0.
|
|
pub fn color_scheme(&self) -> u32 {
|
|
if self.is_dark {
|
|
1
|
|
} else {
|
|
2
|
|
}
|
|
}
|
|
|
|
/// `contrast` de `org.freedesktop.appearance`: 0 = normal,
|
|
/// 1 = contraste alto.
|
|
pub fn contrast(&self) -> u32 {
|
|
u32::from(self.high_contrast)
|
|
}
|
|
|
|
/// Acento como RGB en 0..1, el formato `(ddd)` que pide el portal.
|
|
pub fn accent_rgb(&self) -> (f64, f64, f64) {
|
|
let (h, s, l) = self.accent_hsl;
|
|
hsl_to_rgb(h, s, l)
|
|
}
|
|
}
|
|
|
|
/// Tema por defecto si el nombre persistido no se reconoce: oscuro, sin
|
|
/// acento marcado (gris neutro). Degradación segura ante un preset
|
|
/// futuro que esta tabla aún no conozca.
|
|
pub const FALLBACK: ThemeFacts = ThemeFacts {
|
|
is_dark: true,
|
|
high_contrast: false,
|
|
accent_hsl: (0.0, 0.0, 0.5),
|
|
};
|
|
|
|
/// Mapea el nombre persistido de un tema a sus hechos. Espeja
|
|
/// `nahual_theme::Theme::all()` (8 presets al 2026-05-21). Los números
|
|
/// de acento están copiados literalmente de `nahual-theme/src/lib.rs`.
|
|
pub fn facts_for(name: &str) -> ThemeFacts {
|
|
match name.trim() {
|
|
"Nebula" => ThemeFacts {
|
|
is_dark: true,
|
|
high_contrast: false,
|
|
accent_hsl: (280.0, 0.65, 0.65),
|
|
},
|
|
"Aurora" => ThemeFacts {
|
|
is_dark: true,
|
|
high_contrast: false,
|
|
accent_hsl: (150.0, 0.70, 0.55),
|
|
},
|
|
"Sunset" => ThemeFacts {
|
|
is_dark: true,
|
|
high_contrast: false,
|
|
accent_hsl: (15.0, 0.78, 0.62),
|
|
},
|
|
"Flat Dark" => ThemeFacts {
|
|
is_dark: true,
|
|
high_contrast: false,
|
|
accent_hsl: (210.0, 0.70, 0.55),
|
|
},
|
|
"Solarized Light" => ThemeFacts {
|
|
is_dark: false,
|
|
high_contrast: false,
|
|
accent_hsl: (205.0, 0.69, 0.42),
|
|
},
|
|
"High Contrast" => ThemeFacts {
|
|
is_dark: true,
|
|
high_contrast: true,
|
|
accent_hsl: (60.0, 1.00, 0.60),
|
|
},
|
|
"Print Color" => ThemeFacts {
|
|
is_dark: false,
|
|
high_contrast: false,
|
|
accent_hsl: (15.0, 0.70, 0.40),
|
|
},
|
|
"Print B&W" => ThemeFacts {
|
|
is_dark: false,
|
|
high_contrast: false,
|
|
accent_hsl: (0.0, 0.00, 0.20),
|
|
},
|
|
_ => FALLBACK,
|
|
}
|
|
}
|
|
|
|
/// HSL → RGB. `h` en grados 0..360, `s` y `l` en 0..1. Devuelve RGB en
|
|
/// 0..1. Algoritmo estándar (croma + segmento del matiz).
|
|
fn hsl_to_rgb(h: f64, s: f64, l: f64) -> (f64, f64, f64) {
|
|
let c = (1.0 - (2.0 * l - 1.0).abs()) * s;
|
|
let h_prime = h.rem_euclid(360.0) / 60.0;
|
|
let x = c * (1.0 - (h_prime % 2.0 - 1.0).abs());
|
|
let (r1, g1, b1) = match h_prime 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),
|
|
};
|
|
let m = l - c / 2.0;
|
|
(r1 + m, g1 + m, b1 + m)
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
fn approx(a: f64, b: f64) -> bool {
|
|
(a - b).abs() < 1e-6
|
|
}
|
|
|
|
fn rgb_eq(got: (f64, f64, f64), want: (f64, f64, f64)) -> bool {
|
|
approx(got.0, want.0) && approx(got.1, want.1) && approx(got.2, want.2)
|
|
}
|
|
|
|
#[test]
|
|
fn hsl_primaries() {
|
|
assert!(rgb_eq(hsl_to_rgb(0.0, 1.0, 0.5), (1.0, 0.0, 0.0)));
|
|
assert!(rgb_eq(hsl_to_rgb(120.0, 1.0, 0.5), (0.0, 1.0, 0.0)));
|
|
assert!(rgb_eq(hsl_to_rgb(240.0, 1.0, 0.5), (0.0, 0.0, 1.0)));
|
|
}
|
|
|
|
#[test]
|
|
fn hsl_grays() {
|
|
assert!(rgb_eq(hsl_to_rgb(0.0, 0.0, 0.0), (0.0, 0.0, 0.0)));
|
|
assert!(rgb_eq(hsl_to_rgb(0.0, 0.0, 1.0), (1.0, 1.0, 1.0)));
|
|
// Acento de "Print B&W": gris medio-oscuro.
|
|
assert!(rgb_eq(hsl_to_rgb(0.0, 0.0, 0.2), (0.2, 0.2, 0.2)));
|
|
}
|
|
|
|
#[test]
|
|
fn known_themes_map_color_scheme() {
|
|
assert_eq!(facts_for("Nebula").color_scheme(), 1);
|
|
assert_eq!(facts_for("Aurora").color_scheme(), 1);
|
|
assert_eq!(facts_for("Solarized Light").color_scheme(), 2);
|
|
assert_eq!(facts_for("Print Color").color_scheme(), 2);
|
|
}
|
|
|
|
#[test]
|
|
fn high_contrast_only_for_high_contrast_theme() {
|
|
assert!(facts_for("High Contrast").high_contrast);
|
|
assert_eq!(facts_for("High Contrast").contrast(), 1);
|
|
assert!(!facts_for("Nebula").high_contrast);
|
|
assert_eq!(facts_for("Nebula").contrast(), 0);
|
|
}
|
|
|
|
#[test]
|
|
fn unknown_theme_falls_back() {
|
|
let f = facts_for("NoSuchTheme");
|
|
assert_eq!(f, FALLBACK);
|
|
assert_eq!(f.color_scheme(), 1, "FALLBACK es oscuro");
|
|
}
|
|
|
|
#[test]
|
|
fn accent_rgb_in_range() {
|
|
for name in [
|
|
"Nebula",
|
|
"Aurora",
|
|
"Sunset",
|
|
"Flat Dark",
|
|
"Solarized Light",
|
|
"High Contrast",
|
|
"Print Color",
|
|
"Print B&W",
|
|
] {
|
|
let (r, g, b) = facts_for(name).accent_rgb();
|
|
for ch in [r, g, b] {
|
|
assert!(
|
|
(0.0..=1.0).contains(&ch),
|
|
"{name}: canal fuera de rango: {ch}"
|
|
);
|
|
}
|
|
}
|
|
}
|
|
}
|