feat(theme): exportación a GTK + inyección de entorno en el compositor

Segunda mitad de la uniformización del tema. nahual-theme::toolkit
traduce el Theme activo a gtk-3.0/gtk.css y gtk-4.0/gtk.css con overrides
@define-color (acento exacto + neutro claro/oscuro sintetizado).
Theme::set/install_default exportan best-effort; guarda de no-pisar
respeta un gtk.css ajeno. El compositor inyecta XDG_CURRENT_DESKTOP=mirada
y QT_QPA_PLATFORMTHEME=gtk3 a cada hijo, así GTK y Qt siguen el tema.

8 tests nuevos en toolkit; ejemplo dump-toolkit-css.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
sergio
2026-05-21 17:41:35 +00:00
parent 5369c307e4
commit af3be482a9
6 changed files with 439 additions and 7 deletions
+20 -3
View File
@@ -722,16 +722,33 @@ fn cursor_hotspot(surface: &WlSurface) -> (i32, i32) {
})
}
/// Variables de entorno de tema que el compositor inyecta a cada hijo,
/// para uniformizar GTK y Qt:
/// - `XDG_CURRENT_DESKTOP=mirada` hace que `xdg-desktop-portal` enrute
/// hacia `mirada-portal` (el backend de `org.freedesktop.appearance`).
/// - `QT_QPA_PLATFORMTHEME=gtk3` hace que las apps Qt sigan el tema GTK,
/// y por tanto el `gtk.css` que genera `nahual-theme`.
const THEME_ENV: &[(&str, &str)] = &[
("XDG_CURRENT_DESKTOP", "mirada"),
("QT_QPA_PLATFORMTHEME", "gtk3"),
];
/// Lanza un comando como proceso hijo, vía `sh -c`. El hijo hereda el
/// entorno —`WAYLAND_DISPLAY` incluido—, así que el cliente que abra se
/// conecta a este compositor. Lo usan la acción `spawn:…` del keymap, la
/// variable `MIRADA_STARTUP` y el autoarranque.
/// conecta a este compositor; además se le inyecta [`THEME_ENV`] para
/// que GTK y Qt adopten el tema del escritorio. Lo usan la acción
/// `spawn:…` del keymap, la variable `MIRADA_STARTUP` y el autoarranque.
fn spawn_command(cmd: &str) {
let cmd = cmd.trim();
if cmd.is_empty() {
return;
}
match std::process::Command::new("sh").arg("-c").arg(cmd).spawn() {
match std::process::Command::new("sh")
.arg("-c")
.arg(cmd)
.envs(THEME_ENV.iter().copied())
.spawn()
{
Ok(child) => println!("mirada-compositor · lanzado (pid {}): {cmd}", child.id()),
Err(e) => eprintln!("mirada-compositor · no pude lanzar «{cmd}»: {e}"),
}
@@ -0,0 +1,17 @@
//! Vuelca a stdout el `gtk.css` que `nahual-theme` generaría para cada
//! preset. Útil para inspeccionar o depurar la exportación a toolkits
//! sin tener que cambiar de tema en una app real.
//!
//! `cargo run -p nahual-theme --example dump-toolkit-css`
use nahual_theme::{toolkit, Theme};
fn main() {
for theme in Theme::all() {
println!("\n================= {} =================", theme.name);
println!("--- gtk-4.0/gtk.css ---");
print!("{}", toolkit::gtk4_css(&theme));
println!("--- gtk-3.0/gtk.css ---");
print!("{}", toolkit::gtk3_css(&theme));
}
}
+17 -4
View File
@@ -8,7 +8,9 @@
//! ningún widget concreto. Cada widget pide slots semánticos (panel_bg,
//! row_hover, accent…) sin acoplarse a colores hex específicos.
use gpui::{Background, Global, Hsla, hsla, linear_color_stop, linear_gradient};
use gpui::{hsla, linear_color_stop, linear_gradient, Background, Global, Hsla};
pub mod toolkit;
/// Paleta semántica del theme. Cada slot tiene un nombre funcional, no
/// cromático — así los widgets piden "fondo de panel" sin acoplarse a
@@ -121,6 +123,8 @@ impl Theme {
/// theme cambia (típicamente vía `theme_switcher`).
pub fn install_default(cx: &mut gpui::App) {
let theme = load_persisted().unwrap_or_else(Self::nebula);
// Asegura que GTK/Qt arranquen con el `gtk.css` del tema activo.
let _ = toolkit::export_toolkit_configs(&theme);
cx.set_global(theme);
}
@@ -134,6 +138,8 @@ impl Theme {
/// por un I/O secundario.
pub fn set(cx: &mut gpui::App, theme: Self) {
let _ = persist(&theme);
// Reexporta `gtk.css` para que las apps GTK/Qt sigan el cambio.
let _ = toolkit::export_toolkit_configs(&theme);
cx.set_global(theme);
}
@@ -484,7 +490,15 @@ const CONFIG_FILE: &str = "theme";
/// sino `$HOME/.config/nahual/theme`. `None` si ni `XDG_CONFIG_HOME`
/// ni `HOME` están definidos (típicamente en sandboxes / CI).
pub fn config_path() -> Option<PathBuf> {
let base = std::env::var("XDG_CONFIG_HOME")
Some(config_home()?.join(CONFIG_SUBDIR).join(CONFIG_FILE))
}
/// Directorio base de configuración del usuario: `$XDG_CONFIG_HOME` si
/// está definido, sino `$HOME/.config`. `None` si ninguno está set
/// (típicamente en sandboxes / CI). Lo usan [`config_path`] y el módulo
/// [`toolkit`] para ubicar `gtk-3.0/gtk.css` y `gtk-4.0/gtk.css`.
pub(crate) fn config_home() -> Option<PathBuf> {
std::env::var("XDG_CONFIG_HOME")
.ok()
.filter(|s| !s.is_empty())
.map(PathBuf::from)
@@ -493,8 +507,7 @@ pub fn config_path() -> Option<PathBuf> {
.ok()
.filter(|s| !s.is_empty())
.map(|h| PathBuf::from(h).join(".config"))
})?;
Some(base.join(CONFIG_SUBDIR).join(CONFIG_FILE))
})
}
/// Lee el theme persistido. `None` si: no hay config dir, file no
@@ -0,0 +1,350 @@
//! Exportación del tema a los toolkits del escritorio (GTK 3 y 4).
//!
//! `nahual` pinta sus apps con GPUI, pero el escritorio carmen también
//! corre apps GTK y Qt. Para que no desentonen, este módulo traduce el
//! [`Theme`] activo a archivos `gtk.css` con overrides `@define-color`:
//! GTK adopta el acento y el neutro claro/oscuro del tema. Las apps Qt
//! se enganchan vía `QT_QPA_PLATFORMTHEME=gtk3` (lo inyecta el
//! compositor a cada hijo), así que siguen a GTK sin config aparte.
//!
//! La paleta de `nahual` usa gradientes (`Background`) que GTK no puede
//! reproducir en una ventana sólida; por eso el neutro GTK se **deriva**
//! de `is_dark` + los slots `Hsla` accesibles (`accent`, `fg_text`,
//! `border`). El acento sí se traslada exacto.
//!
//! [`Theme::set`](super::Theme::set) y
//! [`Theme::install_default`](super::Theme::install_default) llaman a
//! [`export_toolkit_configs`] best-effort.
use std::io;
use std::path::{Path, PathBuf};
use gpui::{hsla, Hsla, Rgba};
use super::{config_home, Theme};
/// Marca en la cabecera de los archivos generados. Si un `gtk.css` ya
/// existe **sin** esta marca, lo dejamos intacto: es del usuario.
const MARKER: &str = "generado por nahual-theme";
/// Resultado de [`export_toolkit_configs`]: qué archivos se (re)escribieron
/// y cuáles se respetaron por ser ajenos al control de nahual.
#[derive(Debug, Default, Clone)]
pub struct ExportReport {
/// Archivos `gtk.css` (re)escritos por nahual.
pub written: Vec<PathBuf>,
/// Archivos saltados: existían y no llevan la marca de nahual, así
/// que son del usuario y no se tocan.
pub skipped: Vec<PathBuf>,
}
/// Escribe `gtk-3.0/gtk.css` y `gtk-4.0/gtk.css` bajo el directorio de
/// config del usuario, derivados del `theme`. Best-effort: el caller
/// (`Theme::set`) ignora el resultado. No pisa un `gtk.css` ajeno —
/// ver [`ExportReport::skipped`].
pub fn export_toolkit_configs(theme: &Theme) -> io::Result<ExportReport> {
let base = config_home().ok_or_else(|| {
io::Error::new(
io::ErrorKind::NotFound,
"no se pudo determinar config dir (HOME/XDG_CONFIG_HOME no set)",
)
})?;
export_toolkit_configs_to(&base, theme)
}
/// Variante de [`export_toolkit_configs`] con directorio base de config
/// explícito. Útil para tests y para apps con su propio config root.
pub fn export_toolkit_configs_to(base: &Path, theme: &Theme) -> io::Result<ExportReport> {
let targets = [
(base.join("gtk-3.0").join("gtk.css"), gtk3_css(theme)),
(base.join("gtk-4.0").join("gtk.css"), gtk4_css(theme)),
];
let mut report = ExportReport::default();
for (path, content) in targets {
if is_foreign(&path) {
report.skipped.push(path);
continue;
}
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
std::fs::write(&path, content)?;
report.written.push(path);
}
Ok(report)
}
/// `true` si el archivo existe y NO lleva la marca de nahual — es un
/// `gtk.css` propio del usuario y no debemos pisarlo. Si no existe (o no
/// se puede leer), `false`: campo libre para escribir.
fn is_foreign(path: &Path) -> bool {
match std::fs::read_to_string(path) {
Ok(existing) => !existing.contains(MARKER),
Err(_) => false,
}
}
// ============================================================================
// Generación de CSS
// ============================================================================
/// CSS para `~/.config/gtk-4.0/gtk.css` — nombres de color de libadwaita.
pub fn gtk4_css(theme: &Theme) -> String {
let p = gtk_palette(theme);
let mut s = header(theme, "GTK 4 / libadwaita");
define(&mut s, "accent_color", &p.accent);
define(&mut s, "accent_bg_color", &p.accent);
define(&mut s, "accent_fg_color", &p.accent_fg);
define(&mut s, "window_bg_color", &p.window_bg);
define(&mut s, "window_fg_color", &p.window_fg);
define(&mut s, "view_bg_color", &p.view_bg);
define(&mut s, "view_fg_color", &p.window_fg);
define(&mut s, "headerbar_bg_color", &p.headerbar_bg);
define(&mut s, "headerbar_fg_color", &p.window_fg);
define(&mut s, "card_bg_color", &p.card_bg);
define(&mut s, "card_fg_color", &p.window_fg);
define(&mut s, "popover_bg_color", &p.popover_bg);
define(&mut s, "popover_fg_color", &p.window_fg);
define(&mut s, "dialog_bg_color", &p.dialog_bg);
define(&mut s, "dialog_fg_color", &p.window_fg);
define(&mut s, "sidebar_bg_color", &p.sidebar_bg);
define(&mut s, "sidebar_fg_color", &p.window_fg);
define(&mut s, "destructive_color", &p.destructive);
define(&mut s, "destructive_bg_color", &p.destructive);
define(&mut s, "destructive_fg_color", &p.accent_fg);
define(&mut s, "borders", &p.border);
s
}
/// CSS para `~/.config/gtk-3.0/gtk.css` — nombres de color de Adwaita 3.
pub fn gtk3_css(theme: &Theme) -> String {
let p = gtk_palette(theme);
let mut s = header(theme, "GTK 3");
define(&mut s, "theme_bg_color", &p.window_bg);
define(&mut s, "theme_fg_color", &p.window_fg);
define(&mut s, "theme_base_color", &p.view_bg);
define(&mut s, "theme_text_color", &p.window_fg);
define(&mut s, "theme_selected_bg_color", &p.accent);
define(&mut s, "theme_selected_fg_color", &p.accent_fg);
define(&mut s, "theme_tooltip_bg_color", &p.popover_bg);
define(&mut s, "theme_tooltip_fg_color", &p.window_fg);
define(&mut s, "insensitive_bg_color", &p.window_bg);
define(&mut s, "insensitive_fg_color", &p.fg_disabled);
define(&mut s, "borders", &p.border);
define(&mut s, "warning_color", &p.destructive);
define(&mut s, "error_color", &p.destructive);
s
}
/// Cabecera común: lleva la [`MARKER`] (la guarda de no-pisar) y avisa
/// que el archivo es regenerado.
fn header(theme: &Theme, toolkit: &str) -> String {
format!(
"/* {MARKER} · tema «{}» · {toolkit} */\n\
/* Lo reescribe nahual al cambiar de tema. Para usar tu propio\n\
\x20 gtk.css, borra esta cabecera y nahual respetará el archivo. */\n\n",
theme.name
)
}
/// Agrega una línea `@define-color <name> <value>;`.
fn define(out: &mut String, name: &str, value: &str) {
out.push_str("@define-color ");
out.push_str(name);
out.push(' ');
out.push_str(value);
out.push_str(";\n");
}
/// Paleta intermedia: los colores GTK ya resueltos a hex `#rrggbb`.
struct GtkPalette {
accent: String,
accent_fg: String,
window_bg: String,
window_fg: String,
view_bg: String,
headerbar_bg: String,
card_bg: String,
popover_bg: String,
sidebar_bg: String,
dialog_bg: String,
fg_disabled: String,
border: String,
destructive: String,
}
/// Deriva la paleta GTK del `Theme`. El acento y los foregrounds son
/// directos; los fondos neutros se sintetizan con un ramp de luminancia
/// (GTK pinta ventanas sólidas, no gradientes), tintado con el matiz del
/// borde del tema para que conserve su "temperatura".
fn gtk_palette(t: &Theme) -> GtkPalette {
let nh = t.border.h;
let ns = (t.border.s * 0.7).min(0.5);
let neutral = |l: f32| hex(hsla(nh, ns, l, 1.0));
// (window, view, headerbar, card, popover, sidebar, dialog)
let lum = if t.is_dark {
[0.11, 0.07, 0.14, 0.15, 0.17, 0.09, 0.13]
} else {
[0.95, 0.99, 0.90, 0.98, 0.99, 0.93, 0.96]
};
GtkPalette {
accent: hex(t.accent),
accent_fg: hex(contrast_fg(t.accent)),
window_bg: neutral(lum[0]),
window_fg: hex(t.fg_text),
view_bg: neutral(lum[1]),
headerbar_bg: neutral(lum[2]),
card_bg: neutral(lum[3]),
popover_bg: neutral(lum[4]),
sidebar_bg: neutral(lum[5]),
dialog_bg: neutral(lum[6]),
fg_disabled: hex(t.fg_disabled),
border: hex(t.border),
destructive: hex(t.accent_destructive()),
}
}
/// Color de texto legible sobre `bg`: casi-negro si el fondo es claro,
/// casi-blanco si es oscuro. Decide por luma perceptual.
fn contrast_fg(bg: Hsla) -> Hsla {
let rgba: Rgba = bg.into();
let luma = 0.299 * rgba.r + 0.587 * rgba.g + 0.114 * rgba.b;
if luma > 0.55 {
hsla(0.0, 0.0, 0.08, 1.0)
} else {
hsla(0.0, 0.0, 0.98, 1.0)
}
}
/// `Hsla` → `#rrggbb`. Descarta el alfa (GTK lo maneja aparte).
fn hex(c: Hsla) -> String {
let rgba: Rgba = c.into();
let q = |f: f32| (f.clamp(0.0, 1.0) * 255.0).round() as u8;
format!("#{:02x}{:02x}{:02x}", q(rgba.r), q(rgba.g), q(rgba.b))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn hex_of_pure_colors() {
assert_eq!(hex(hsla(0.0, 0.0, 0.0, 1.0)), "#000000");
assert_eq!(hex(hsla(0.0, 0.0, 1.0, 1.0)), "#ffffff");
assert_eq!(hex(hsla(0.0, 1.0, 0.5, 1.0)), "#ff0000");
}
#[test]
fn gtk4_css_carries_marker_and_accent() {
let css = gtk4_css(&Theme::nebula());
assert!(css.contains(MARKER), "falta la marca de no-pisar");
assert!(css.contains("@define-color accent_color #"));
assert!(css.contains("@define-color window_bg_color #"));
}
#[test]
fn gtk3_css_carries_selection() {
let css = gtk3_css(&Theme::aurora());
assert!(css.contains(MARKER));
assert!(css.contains("@define-color theme_selected_bg_color #"));
}
#[test]
fn every_preset_yields_well_formed_hex() {
for theme in Theme::all() {
let p = gtk_palette(&theme);
for color in [&p.accent, &p.window_bg, &p.view_bg, &p.border] {
assert_eq!(color.len(), 7, "{}: hex mal formado: {color}", theme.name);
assert!(color.starts_with('#'), "{}: sin #: {color}", theme.name);
assert!(
color[1..].chars().all(|c| c.is_ascii_hexdigit()),
"{}: dígitos no-hex: {color}",
theme.name
);
}
}
}
#[test]
fn light_theme_has_light_window_bg() {
// Solarized Light es claro: su window_bg debe ser luminoso.
let p = gtk_palette(&Theme::solarized_light());
let v = u8::from_str_radix(&p.window_bg[1..3], 16).unwrap();
assert!(
v > 200,
"fondo de tema claro demasiado oscuro: {}",
p.window_bg
);
}
#[test]
fn dark_theme_has_dark_window_bg() {
let p = gtk_palette(&Theme::nebula());
let v = u8::from_str_radix(&p.window_bg[1..3], 16).unwrap();
assert!(
v < 60,
"fondo de tema oscuro demasiado claro: {}",
p.window_bg
);
}
#[test]
fn export_writes_both_and_respects_foreign() {
let mut dir = std::env::temp_dir();
dir.push(format!(
"nahual-toolkit-export-{}-{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_nanos())
.unwrap_or(0),
));
let gtk3 = dir.join("gtk-3.0").join("gtk.css");
let gtk4 = dir.join("gtk-4.0").join("gtk.css");
// Primera exportación: crea los dos archivos.
let r1 = export_toolkit_configs_to(&dir, &Theme::nebula()).unwrap();
assert_eq!(r1.written.len(), 2);
assert!(r1.skipped.is_empty());
assert!(gtk3.exists() && gtk4.exists());
// Re-exportar otro tema: nuestros archivos llevan la marca, se
// reescriben sin problema.
let r2 = export_toolkit_configs_to(&dir, &Theme::aurora()).unwrap();
assert_eq!(r2.written.len(), 2);
assert!(std::fs::read_to_string(&gtk4).unwrap().contains("Aurora"));
// Un `gtk.css` ajeno (sin marca) se respeta: no se pisa.
std::fs::write(&gtk3, "/* css del usuario */\n").unwrap();
let r3 = export_toolkit_configs_to(&dir, &Theme::sunset()).unwrap();
assert_eq!(r3.written, vec![gtk4.clone()]);
assert_eq!(r3.skipped, vec![gtk3.clone()]);
assert_eq!(
std::fs::read_to_string(&gtk3).unwrap(),
"/* css del usuario */\n"
);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn is_foreign_detects_user_file() {
let mut path = std::env::temp_dir();
path.push(format!("nahual-toolkit-test-{}.css", std::process::id()));
// Archivo ajeno (sin marca) → foreign.
std::fs::write(&path, "/* mi css */\n").unwrap();
assert!(is_foreign(&path));
// Archivo nuestro (con marca) → no foreign.
std::fs::write(&path, gtk4_css(&Theme::nebula())).unwrap();
assert!(!is_foreign(&path));
let _ = std::fs::remove_file(&path);
// Archivo inexistente → no foreign (campo libre).
assert!(!is_foreign(&path));
}
}
+14
View File
@@ -5,6 +5,20 @@ 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): inyección de entorno de tema a los hijos del compositor
`spawn_command` del compositor inyecta `THEME_ENV` a cada proceso hijo:
- `XDG_CURRENT_DESKTOP=mirada``xdg-desktop-portal` enruta hacia el
backend `mirada-portal`.
- `QT_QPA_PLATFORMTHEME=gtk3` → las apps Qt siguen el tema GTK, y por
tanto el `gtk.css` que genera `nahual-theme`.
Cierra la segunda mitad de la uniformización del tema: portal
(claro/oscuro + acento por protocolo) + `gtk.css` generado por
`nahual-theme::toolkit` + esta inyección → GTK y Qt alineados con el
tema de nahual.
### feat(mirada-portal): backend de tema — org.freedesktop.appearance
App nueva `crates/apps/mirada-portal`: backend de `xdg-desktop-portal`
+21
View File
@@ -2,6 +2,27 @@
Motor GPUI: libs + widgets. Renombrado de `yahweh` el 2026-05-19.
### feat(nahual-theme): exportación del tema a GTK (módulo toolkit)
Módulo nuevo `nahual-theme/src/toolkit.rs`: traduce el `Theme` activo a
`~/.config/gtk-3.0/gtk.css` y `gtk-4.0/gtk.css` con overrides
`@define-color`. Las apps GTK adoptan el acento exacto del tema + un
neutro claro/oscuro coherente. Los fondos en gradiente de nahual no se
pueden reproducir en ventanas GTK sólidas, así que el neutro se sintetiza
con un ramp de luminancia tintado por el matiz del borde del tema.
- `gtk4_css` / `gtk3_css` — generadores puros (nombres de color de
libadwaita y de Adwaita 3 respectivamente).
- `export_toolkit_configs` + `export_toolkit_configs_to(base)`
escritura; el segundo con directorio base explícito para tests.
- **Guarda de no-pisar**: si un `gtk.css` ya existe sin la marca de
nahual, es del usuario y se respeta (`ExportReport.skipped`).
- `Theme::set` y `Theme::install_default` exportan best-effort: cambiar
de tema en cualquier app GPUI actualiza GTK al instante.
- `config_path` refactorizado sobre un nuevo `config_home()`.
- Ejemplo `dump-toolkit-css` para inspeccionar el CSS generado.
- 8 tests nuevos en `toolkit`.
### feat(yahweh-launcher): F3 — extracción del shell standard de explorers
Iter 19. Patrón con 4 consumers idénticos (nakui-explorer,
nouser-explorer, minga-explorer, brahman-broker-explorer) declaraban