feat(mirada): mirada-portal — backend de tema org.freedesktop.appearance
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>
This commit is contained in:
Generated
+12
@@ -7759,6 +7759,18 @@ dependencies = [
|
|||||||
"serde",
|
"serde",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "mirada-portal"
|
||||||
|
version = "0.1.0"
|
||||||
|
dependencies = [
|
||||||
|
"anyhow",
|
||||||
|
"notify",
|
||||||
|
"tokio",
|
||||||
|
"tracing",
|
||||||
|
"tracing-subscriber",
|
||||||
|
"zbus 4.4.0",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "mirada-protocol"
|
name = "mirada-protocol"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
|
|||||||
@@ -289,6 +289,7 @@ members = [
|
|||||||
"crates/apps/mirada-compositor",
|
"crates/apps/mirada-compositor",
|
||||||
"crates/apps/mirada-ctl",
|
"crates/apps/mirada-ctl",
|
||||||
"crates/apps/mirada-launcher",
|
"crates/apps/mirada-launcher",
|
||||||
|
"crates/apps/mirada-portal",
|
||||||
]
|
]
|
||||||
|
|
||||||
[workspace.package]
|
[workspace.package]
|
||||||
|
|||||||
@@ -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 }
|
||||||
@@ -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.
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
[preferred]
|
||||||
|
default=mirada
|
||||||
|
org.freedesktop.impl.portal.Settings=mirada
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
[portal]
|
||||||
|
DBusName=org.freedesktop.impl.portal.desktop.mirada
|
||||||
|
Interfaces=org.freedesktop.impl.portal.Settings
|
||||||
|
UseIn=mirada
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
[D-BUS Service]
|
||||||
|
Name=org.freedesktop.impl.portal.desktop.mirada
|
||||||
|
Exec=/usr/bin/mirada-portal
|
||||||
@@ -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.<id>` es el que espera el frontend `xdg-desktop-portal`; el
|
||||||
|
/// `<id>` (`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<Mutex<ThemeFacts>>,
|
||||||
|
theme_path: Option<PathBuf>,
|
||||||
|
) -> 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<Mutex<ThemeFacts>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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<String>,
|
||||||
|
) -> fdo::Result<HashMap<String, HashMap<String, OwnedValue>>> {
|
||||||
|
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<OwnedValue> {
|
||||||
|
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<OwnedValue> {
|
||||||
|
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<HashMap<String, OwnedValue>> {
|
||||||
|
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<OwnedValue> {
|
||||||
|
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> {
|
||||||
|
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<Mutex<ThemeFacts>>,
|
||||||
|
path: PathBuf,
|
||||||
|
) -> notify::Result<notify::RecommendedWatcher> {
|
||||||
|
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<notify::Event>| {
|
||||||
|
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<PathBuf> {
|
||||||
|
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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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.
|
||||||
Reference in New Issue
Block a user