From 5369c307e411a8c8dd5e1f49bb37a53b98a83123 Mon Sep 17 00:00:00 2001 From: sergio Date: Thu, 21 May 2026 16:05:27 +0000 Subject: [PATCH] =?UTF-8?q?feat(mirada):=20mirada-portal=20=E2=80=94=20bac?= =?UTF-8?q?kend=20de=20tema=20org.freedesktop.appearance?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- Cargo.lock | 12 + Cargo.toml | 1 + crates/apps/mirada-portal/Cargo.toml | 21 + crates/apps/mirada-portal/README.md | 76 ++++ .../mirada-portal/data/mirada-portals.conf | 3 + crates/apps/mirada-portal/data/mirada.portal | 4 + ...desktop.impl.portal.desktop.mirada.service | 3 + crates/apps/mirada-portal/src/main.rs | 430 ++++++++++++++++++ crates/apps/mirada-portal/src/theme_facts.rs | 199 ++++++++ docs/changelog/mirada.md | 40 ++ 10 files changed, 789 insertions(+) create mode 100644 crates/apps/mirada-portal/Cargo.toml create mode 100644 crates/apps/mirada-portal/README.md create mode 100644 crates/apps/mirada-portal/data/mirada-portals.conf create mode 100644 crates/apps/mirada-portal/data/mirada.portal create mode 100644 crates/apps/mirada-portal/data/org.freedesktop.impl.portal.desktop.mirada.service create mode 100644 crates/apps/mirada-portal/src/main.rs create mode 100644 crates/apps/mirada-portal/src/theme_facts.rs create mode 100644 docs/changelog/mirada.md diff --git a/Cargo.lock b/Cargo.lock index 256ff4b..72d26f9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -7759,6 +7759,18 @@ dependencies = [ "serde", ] +[[package]] +name = "mirada-portal" +version = "0.1.0" +dependencies = [ + "anyhow", + "notify", + "tokio", + "tracing", + "tracing-subscriber", + "zbus 4.4.0", +] + [[package]] name = "mirada-protocol" version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml index 99ea498..01d39d6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -289,6 +289,7 @@ members = [ "crates/apps/mirada-compositor", "crates/apps/mirada-ctl", "crates/apps/mirada-launcher", + "crates/apps/mirada-portal", ] [workspace.package] diff --git a/crates/apps/mirada-portal/Cargo.toml b/crates/apps/mirada-portal/Cargo.toml new file mode 100644 index 0000000..7ecf3fd --- /dev/null +++ b/crates/apps/mirada-portal/Cargo.toml @@ -0,0 +1,21 @@ +[package] +name = "mirada-portal" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +authors.workspace = true +publish.workspace = true +description = "mirada-portal — backend xdg-desktop-portal de carmen: implementa org.freedesktop.impl.portal.Settings y publica el tema activo de nahual (claro/oscuro + acento + contraste) a GTK, Qt, Firefox y Chromium por protocolo." + +[[bin]] +name = "mirada-portal" +path = "src/main.rs" + +[dependencies] +zbus = { workspace = true } +notify = { workspace = true } +tokio = { workspace = true } +tracing = { workspace = true } +tracing-subscriber = { workspace = true } +anyhow = { workspace = true } diff --git a/crates/apps/mirada-portal/README.md b/crates/apps/mirada-portal/README.md new file mode 100644 index 0000000..cee38ae --- /dev/null +++ b/crates/apps/mirada-portal/README.md @@ -0,0 +1,76 @@ +# mirada-portal + +Backend de `xdg-desktop-portal` para el escritorio **carmen**. Implementa +`org.freedesktop.impl.portal.Settings` y publica un único namespace: +`org.freedesktop.appearance`. + +## Qué resuelve + +GTK y Qt leen su configuración de sitios incompatibles entre sí. Pero +**ambos** —además de Firefox y Chromium— consultan el portal de +FreeDesktop para saber: + +- `color-scheme` — claro (`2`) u oscuro (`1`), +- `accent-color` — el color de acento como `(ddd)` RGB, +- `contrast` — contraste alto (`1`) o normal (`0`). + +`mirada-portal` responde esas tres claves a partir del tema activo de +`nahual` y, cuando el tema cambia, emite `SettingChanged`: todo el +ecosistema voltea en vivo, **sin tocar un solo archivo de config de las +apps**. + +## Fuente del tema + +El daemon lee `$XDG_CONFIG_HOME/nahual/theme` (el archivo que persiste +`nahual-theme`, con el nombre del preset activo) y lo vigila con +`notify`. La traducción nombre → hechos del portal está en +[`src/theme_facts.rs`], que espeja `nahual_theme::Theme::all()` sin +enlazar GPUI. + +## Arquitectura + +Esto es el **backend** del portal. El frontend genérico +`xdg-desktop-portal` (paquete agnóstico, liviano) enruta las llamadas de +las apps hacia este backend según el archivo `mirada.portal`. No hay que +implementar el frontend. + +## Instalación de los archivos de `data/` + +```sh +install -Dm644 data/mirada.portal \ + /usr/share/xdg-desktop-portal/portals/mirada.portal +install -Dm644 data/mirada-portals.conf \ + /usr/share/xdg-desktop-portal/mirada-portals.conf +install -Dm644 data/org.freedesktop.impl.portal.desktop.mirada.service \ + /usr/share/dbus-1/services/org.freedesktop.impl.portal.desktop.mirada.service +install -Dm755 target/release/mirada-portal /usr/bin/mirada-portal +``` + +El frontend casa `UseIn=mirada` contra `XDG_CURRENT_DESKTOP`, así que +carmen debe exportar `XDG_CURRENT_DESKTOP=mirada`. Alternativamente, el +`mirada-portals.conf` lo fuerza con `default=mirada`. + +`mirada-portal` se puede arrancar desde `~/.config/mirada/autostart` o +dejar que el frontend lo active por D-Bus (de ahí el `.service`). + +## Smoke test (sin frontend ni apps GTK) + +Con un bus de sesión vivo, el backend se puede interrogar directo: + +```sh +busctl --user introspect org.freedesktop.impl.portal.desktop.mirada \ + /org/freedesktop/portal/desktop +busctl --user call org.freedesktop.impl.portal.desktop.mirada \ + /org/freedesktop/portal/desktop \ + org.freedesktop.impl.portal.Settings ReadAll as 0 +``` + +Cambiar `~/.config/nahual/theme` debe disparar una señal `SettingChanged` +(observable con `busctl --user monitor`). + +## Límite conocido (v1) + +El portal `org.freedesktop.appearance` sólo lleva claro/oscuro + acento + +contraste. **No** lleva la paleta completa de `nahual`. Para recolorear +GTK/Qt a los colores exactos del tema hace falta, además, inyección de +entorno + CSS generado en el `spawn` de carmen — siguiente paso del plan. diff --git a/crates/apps/mirada-portal/data/mirada-portals.conf b/crates/apps/mirada-portal/data/mirada-portals.conf new file mode 100644 index 0000000..44099de --- /dev/null +++ b/crates/apps/mirada-portal/data/mirada-portals.conf @@ -0,0 +1,3 @@ +[preferred] +default=mirada +org.freedesktop.impl.portal.Settings=mirada diff --git a/crates/apps/mirada-portal/data/mirada.portal b/crates/apps/mirada-portal/data/mirada.portal new file mode 100644 index 0000000..700cdfa --- /dev/null +++ b/crates/apps/mirada-portal/data/mirada.portal @@ -0,0 +1,4 @@ +[portal] +DBusName=org.freedesktop.impl.portal.desktop.mirada +Interfaces=org.freedesktop.impl.portal.Settings +UseIn=mirada diff --git a/crates/apps/mirada-portal/data/org.freedesktop.impl.portal.desktop.mirada.service b/crates/apps/mirada-portal/data/org.freedesktop.impl.portal.desktop.mirada.service new file mode 100644 index 0000000..1483f32 --- /dev/null +++ b/crates/apps/mirada-portal/data/org.freedesktop.impl.portal.desktop.mirada.service @@ -0,0 +1,3 @@ +[D-BUS Service] +Name=org.freedesktop.impl.portal.desktop.mirada +Exec=/usr/bin/mirada-portal diff --git a/crates/apps/mirada-portal/src/main.rs b/crates/apps/mirada-portal/src/main.rs new file mode 100644 index 0000000..10ba734 --- /dev/null +++ b/crates/apps/mirada-portal/src/main.rs @@ -0,0 +1,430 @@ +//! `mirada-portal` — backend de `xdg-desktop-portal` para el escritorio +//! carmen. +//! +//! Implementa la interfaz `org.freedesktop.impl.portal.Settings` y +//! publica un único namespace: `org.freedesktop.appearance`. Con eso, +//! GTK4/libadwaita, Qt6, Firefox y Chromium leen del sistema — +//! **por protocolo, sin tocar sus archivos de config** — si el +//! escritorio está en modo claro u oscuro, su color de acento y si pide +//! contraste alto. Cuando el tema de `nahual` cambia, el portal emite +//! `SettingChanged` y todas esas apps voltean en vivo. +//! +//! Fuente del tema: el archivo que persiste `nahual-theme` +//! (`$XDG_CONFIG_HOME/nahual/theme`, contiene el nombre del preset +//! activo). El portal lo vigila con `notify` y reexpone sus hechos — +//! ver [`theme_facts`]. +//! +//! Este crate es el **backend** del portal: el frontend genérico +//! `xdg-desktop-portal` lo enruta vía el archivo `mirada.portal`. Ver +//! el README para la instalación de los archivos de `data/`. + +use std::collections::HashMap; +use std::path::{Path, PathBuf}; +use std::sync::{Arc, Mutex}; + +use tokio::signal::unix::{signal, SignalKind}; +use tracing::{info, warn}; +use tracing_subscriber::EnvFilter; +use zbus::zvariant::{OwnedValue, Value}; +use zbus::{fdo, interface, SignalContext}; + +mod theme_facts; +use theme_facts::ThemeFacts; + +/// Nombre de bus del backend. El patrón `org.freedesktop.impl.portal. +/// desktop.` es el que espera el frontend `xdg-desktop-portal`; el +/// `` (`mirada`) tiene que coincidir con el `DBusName` del archivo +/// `mirada.portal`. +const BUS_NAME: &str = "org.freedesktop.impl.portal.desktop.mirada"; + +/// Ruta de objeto canónica de los portales del escritorio. +const OBJ_PATH: &str = "/org/freedesktop/portal/desktop"; + +/// Único namespace que servimos. El estándar moderno que leen GTK, Qt, +/// Firefox y Chromium para claro/oscuro + acento. +const APPEARANCE_NS: &str = "org.freedesktop.appearance"; + +#[tokio::main(flavor = "current_thread")] +async fn main() -> anyhow::Result<()> { + init_tracing(); + info!("mirada-portal: arrancando backend org.freedesktop.impl.portal.Settings"); + + let theme_path = theme_config_path(); + let initial = read_facts(theme_path.as_deref()); + info!( + ?theme_path, + color_scheme = initial.color_scheme(), + contrast = initial.contrast(), + "tema inicial resuelto" + ); + + let facts = Arc::new(Mutex::new(initial)); + let portal = SettingsPortal { + facts: Arc::clone(&facts), + }; + + // El portal vive en el bus de **sesión** (no el de sistema): es un + // servicio del escritorio del usuario, no del sistema. + let conn_result = zbus::connection::Builder::session() + .and_then(|b| b.name(BUS_NAME)) + .and_then(|b| b.serve_at(OBJ_PATH, portal)); + + match conn_result { + Ok(builder) => match builder.build().await { + Ok(conn) => { + info!(name = BUS_NAME, "name adquirido en el bus de sesión"); + run(conn, facts, theme_path).await + } + Err(e) => { + warn!(?e, "no se pudo construir la conexión D-Bus — modo idle"); + wait_for_term().await + } + }, + Err(e) => { + warn!(?e, "builder D-Bus falló (¿hay bus de sesión?) — modo idle"); + wait_for_term().await + } + } +} + +/// Conectado al bus: monta el watcher del tema y espera la señal de +/// término. El watcher se guarda en `_watcher` para que no se dropee +/// (al dropearse dejaría de vigilar). +async fn run( + conn: zbus::Connection, + facts: Arc>, + theme_path: Option, +) -> anyhow::Result<()> { + let _watcher = match &theme_path { + Some(path) => match spawn_theme_watcher(conn.clone(), Arc::clone(&facts), path.clone()) { + Ok(w) => Some(w), + Err(e) => { + warn!( + ?e, + "watcher del tema no disponible — el portal no actualizará en vivo" + ); + None + } + }, + None => { + warn!("sin ruta de config de tema — el portal sirve un valor fijo"); + None + } + }; + wait_for_term().await +} + +// ============================================================================ +// Interfaz D-Bus: org.freedesktop.impl.portal.Settings +// ============================================================================ + +struct SettingsPortal { + /// Hechos del tema activo. El watcher los reescribe cuando cambia. + facts: Arc>, +} + +#[interface(name = "org.freedesktop.impl.portal.Settings")] +impl SettingsPortal { + /// Versión de la interfaz impl. `ReadOne` se agregó en la 2. + #[zbus(property, name = "version")] + fn version(&self) -> u32 { + 2 + } + + /// `ReadAll(namespaces) -> a{sa{sv}}`. Los `namespaces` son patrones + /// (sufijo `*` = prefijo); lista vacía = todos. Sólo respondemos + /// `org.freedesktop.appearance`. + async fn read_all( + &self, + namespaces: Vec, + ) -> fdo::Result>> { + let mut out = HashMap::new(); + if namespace_requested(&namespaces, APPEARANCE_NS) { + let facts = *self.facts.lock().unwrap(); + out.insert(APPEARANCE_NS.to_string(), appearance_map(&facts)?); + } + Ok(out) + } + + /// `ReadOne(namespace, key) -> v`. Lee un único valor. + async fn read_one(&self, namespace: String, key: String) -> fdo::Result { + let facts = *self.facts.lock().unwrap(); + lookup(&facts, &namespace, &key) + } + + /// `Read(namespace, key) -> v`. Deprecado a favor de `ReadOne` desde + /// la versión 2 del portal, pero apps viejas lo siguen llamando. + async fn read(&self, namespace: String, key: String) -> fdo::Result { + let facts = *self.facts.lock().unwrap(); + lookup(&facts, &namespace, &key) + } + + /// `SettingChanged(namespace, key, value)`. Lo emite el watcher + /// cuando el tema persistido cambia. + #[zbus(signal)] + async fn setting_changed( + ctxt: &SignalContext<'_>, + namespace: &str, + key: &str, + value: Value<'_>, + ) -> zbus::Result<()>; +} + +// ============================================================================ +// Mapeo tema → valores del portal +// ============================================================================ + +/// Construye el mapa `a{sv}` del namespace `org.freedesktop.appearance`. +fn appearance_map(facts: &ThemeFacts) -> fdo::Result> { + Ok(HashMap::from([ + ( + "color-scheme".to_string(), + into_owned(Value::U32(facts.color_scheme()))?, + ), + ( + "contrast".to_string(), + into_owned(Value::U32(facts.contrast()))?, + ), + ("accent-color".to_string(), into_owned(accent_value(facts))?), + ])) +} + +/// Resuelve una clave concreta dentro de `org.freedesktop.appearance`. +fn lookup(facts: &ThemeFacts, namespace: &str, key: &str) -> fdo::Result { + if namespace != APPEARANCE_NS { + return Err(fdo::Error::Failed(format!( + "namespace no servido por mirada-portal: {namespace}" + ))); + } + let value = match key { + "color-scheme" => Value::U32(facts.color_scheme()), + "contrast" => Value::U32(facts.contrast()), + "accent-color" => accent_value(facts), + other => { + return Err(fdo::Error::Failed(format!( + "clave desconocida en {APPEARANCE_NS}: {other}" + ))); + } + }; + into_owned(value) +} + +/// El acento como structure `(ddd)` — tres dobles RGB en 0..1. +fn accent_value(facts: &ThemeFacts) -> Value<'static> { + let (r, g, b) = facts.accent_rgb(); + Value::from((r, g, b)) +} + +/// `Value` → `OwnedValue`. Sólo falla con valores que llevan fds; los +/// nuestros (enteros y dobles) nunca lo hacen. +fn into_owned(value: Value<'_>) -> fdo::Result { + OwnedValue::try_from(value).map_err(|e| fdo::Error::Failed(format!("zvariant: {e}"))) +} + +/// ¿El patrón de namespaces de un `ReadAll` pide `ns`? Lista vacía = +/// todos. Un patrón con sufijo `*` matchea por prefijo; sino, exacto. +fn namespace_requested(patterns: &[String], ns: &str) -> bool { + if patterns.is_empty() { + return true; + } + patterns.iter().any(|p| match p.strip_suffix('*') { + Some(prefix) => ns.starts_with(prefix), + None => p == ns, + }) +} + +// ============================================================================ +// Watcher del tema persistido +// ============================================================================ + +/// Vigila el archivo de tema de `nahual`; cuando cambia, recomputa los +/// hechos y emite `SettingChanged`. Devuelve el watcher, que el caller +/// debe mantener vivo. +fn spawn_theme_watcher( + conn: zbus::Connection, + facts: Arc>, + path: PathBuf, +) -> notify::Result { + use notify::{RecursiveMode, Watcher}; + + // Canal acotado: el callback de notify (en su propio hilo) sólo + // hace `try_send`; si el buffer está lleno ya hay un evento + // pendiente y da igual perder éste — coalescencia natural. + let (tx, mut rx) = tokio::sync::mpsc::channel::<()>(8); + let mut watcher = notify::recommended_watcher(move |res: notify::Result| { + if res.is_ok() { + let _ = tx.try_send(()); + } + })?; + + // Vigilamos el **directorio padre**: así captamos también la + // creación del archivo si aún no existe. + let watch_target = path.parent().unwrap_or(&path).to_path_buf(); + watcher.watch(&watch_target, RecursiveMode::NonRecursive)?; + info!(dir = ?watch_target, "vigilando el directorio del tema"); + + tokio::spawn(async move { + while rx.recv().await.is_some() { + let fresh = read_facts(Some(&path)); + let changed = { + let mut guard = facts.lock().unwrap(); + let differs = *guard != fresh; + *guard = fresh; + differs + }; + if changed { + info!( + color_scheme = fresh.color_scheme(), + contrast = fresh.contrast(), + "el tema cambió — emitiendo SettingChanged" + ); + if let Err(e) = emit_appearance_changed(&conn, &fresh).await { + warn!(?e, "no se pudo emitir SettingChanged"); + } + } + } + }); + + Ok(watcher) +} + +/// Emite `SettingChanged` para las tres claves de `appearance`. +async fn emit_appearance_changed(conn: &zbus::Connection, facts: &ThemeFacts) -> zbus::Result<()> { + let ctxt = SignalContext::new(conn, OBJ_PATH)?; + SettingsPortal::setting_changed( + &ctxt, + APPEARANCE_NS, + "color-scheme", + Value::U32(facts.color_scheme()), + ) + .await?; + SettingsPortal::setting_changed( + &ctxt, + APPEARANCE_NS, + "contrast", + Value::U32(facts.contrast()), + ) + .await?; + SettingsPortal::setting_changed(&ctxt, APPEARANCE_NS, "accent-color", accent_value(facts)) + .await?; + Ok(()) +} + +// ============================================================================ +// Lectura del tema persistido +// ============================================================================ + +/// Lee el nombre de tema del archivo y resuelve sus hechos. Si el +/// archivo falta o está vacío, asume `Nebula` — el default de +/// `nahual_theme::install_default`. +fn read_facts(path: Option<&Path>) -> ThemeFacts { + let name = path + .and_then(|p| std::fs::read_to_string(p).ok()) + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()) + .unwrap_or_else(|| "Nebula".to_string()); + theme_facts::facts_for(&name) +} + +/// Ruta del archivo donde `nahual-theme` persiste el nombre del tema +/// activo. Réplica de `nahual_theme::config_path()` — `mirada-portal` +/// no enlaza GPUI, así que no puede llamarla directamente. Convención +/// XDG: `$XDG_CONFIG_HOME/nahual/theme`, sino `$HOME/.config/...`. +fn theme_config_path() -> Option { + let base = std::env::var("XDG_CONFIG_HOME") + .ok() + .filter(|s| !s.is_empty()) + .map(PathBuf::from) + .or_else(|| { + std::env::var("HOME") + .ok() + .filter(|s| !s.is_empty()) + .map(|h| PathBuf::from(h).join(".config")) + })?; + Some(base.join("nahual").join("theme")) +} + +// ============================================================================ +// Plomería +// ============================================================================ + +async fn wait_for_term() -> anyhow::Result<()> { + let mut term = signal(SignalKind::terminate())?; + let mut int_ = signal(SignalKind::interrupt())?; + tokio::select! { + _ = term.recv() => info!("SIGTERM"), + _ = int_.recv() => info!("SIGINT"), + } + Ok(()) +} + +fn init_tracing() { + let filter = + EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("mirada_portal=info")); + tracing_subscriber::fmt() + .with_env_filter(filter) + .with_target(true) + .init(); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn namespace_empty_matches_all() { + assert!(namespace_requested(&[], APPEARANCE_NS)); + } + + #[test] + fn namespace_exact_match() { + assert!(namespace_requested( + &[APPEARANCE_NS.to_string()], + APPEARANCE_NS + )); + assert!(!namespace_requested( + &["org.example".to_string()], + APPEARANCE_NS + )); + } + + #[test] + fn namespace_wildcard_prefix() { + assert!(namespace_requested( + &["org.freedesktop.*".to_string()], + APPEARANCE_NS + )); + assert!(!namespace_requested( + &["org.gnome.*".to_string()], + APPEARANCE_NS + )); + } + + #[test] + fn appearance_map_has_three_keys() { + let facts = theme_facts::facts_for("Nebula"); + let m = appearance_map(&facts).unwrap(); + assert_eq!(m.len(), 3); + assert!(m.contains_key("color-scheme")); + assert!(m.contains_key("accent-color")); + assert!(m.contains_key("contrast")); + } + + #[test] + fn lookup_unknown_namespace_errs() { + let facts = theme_facts::facts_for("Nebula"); + assert!(lookup(&facts, "org.gnome.desktop.interface", "color-scheme").is_err()); + } + + #[test] + fn lookup_unknown_key_errs() { + let facts = theme_facts::facts_for("Nebula"); + assert!(lookup(&facts, APPEARANCE_NS, "no-such-key").is_err()); + } + + #[test] + fn lookup_color_scheme_ok() { + let facts = theme_facts::facts_for("Solarized Light"); + assert!(lookup(&facts, APPEARANCE_NS, "color-scheme").is_ok()); + } +} diff --git a/crates/apps/mirada-portal/src/theme_facts.rs b/crates/apps/mirada-portal/src/theme_facts.rs new file mode 100644 index 0000000..e375cfb --- /dev/null +++ b/crates/apps/mirada-portal/src/theme_facts.rs @@ -0,0 +1,199 @@ +//! 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}" + ); + } + } + } +} diff --git a/docs/changelog/mirada.md b/docs/changelog/mirada.md new file mode 100644 index 0000000..d4876c0 --- /dev/null +++ b/docs/changelog/mirada.md @@ -0,0 +1,40 @@ +# Changelog — mirada + +Compositor Wayland del monorepo (nombre de proyecto: carmen). Arquitectura +Cerebro (GPUI) ↔ Cuerpo (smithay) en dos procesos. El historial previo +vive en `git log` (`feat(mirada): …`); este archivo arranca con el +trabajo de escritorio (tema, DM). + +### feat(mirada-portal): backend de tema — org.freedesktop.appearance + +App nueva `crates/apps/mirada-portal`: backend de `xdg-desktop-portal` +para carmen. Implementa `org.freedesktop.impl.portal.Settings` y publica +un único namespace, `org.freedesktop.appearance`, con tres claves: + +- `color-scheme` — `1` oscuro / `2` claro, +- `accent-color` — `(ddd)` RGB, +- `contrast` — `1` alto / `0` normal. + +GTK4/libadwaita, Qt6, Firefox y Chromium leen ese namespace por +protocolo: con el portal activo, todos respetan claro/oscuro + acento del +escritorio **sin tocar sus archivos de config**. Es la primera mitad de +la uniformización del tema (la segunda es inyección de entorno + CSS +generado para la paleta completa). + +- **Fuente del tema**: el archivo que persiste `nahual-theme` + (`$XDG_CONFIG_HOME/nahual/theme`). El daemon lo vigila con `notify`; + al cambiar, recomputa y emite `SettingChanged` — las apps voltean en + vivo. +- **`theme_facts.rs`**: tabla pura nombre-de-tema → hechos del portal + (oscuro, acento HSL, contraste) + conversión HSL→RGB. Espeja + `nahual_theme::Theme::all()` sin enlazar GPUI, para que el daemon + quede liviano. Nombre desconocido → `FALLBACK` (oscuro, acento gris). +- **D-Bus**: bus de **sesión**, `org.freedesktop.impl.portal.desktop. + mirada` en `/org/freedesktop/portal/desktop`. Mismo patrón `zbus 4` + que los shims de `compat/`. +- **`data/`**: `mirada.portal` + `mirada-portals.conf` + el `.service` + de activación D-Bus. El frontend genérico `xdg-desktop-portal` enruta + hacia este backend; ver el README del crate. +- 13 tests (`theme_facts` + helpers del protocolo). Smoke verificado + sobre un bus de sesión efímero (`dbus-run-session`): `ReadAll`, + `ReadOne`, `Read` y emisión de `SettingChanged` al cambiar el tema.