diff --git a/crates/apps/mirada-compositor/src/main.rs b/crates/apps/mirada-compositor/src/main.rs index b313563..4bb4602 100644 --- a/crates/apps/mirada-compositor/src/main.rs +++ b/crates/apps/mirada-compositor/src/main.rs @@ -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}"), } diff --git a/crates/modules/nahual/libs/theme/examples/dump-toolkit-css.rs b/crates/modules/nahual/libs/theme/examples/dump-toolkit-css.rs new file mode 100644 index 0000000..ea40bb3 --- /dev/null +++ b/crates/modules/nahual/libs/theme/examples/dump-toolkit-css.rs @@ -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)); + } +} diff --git a/crates/modules/nahual/libs/theme/src/lib.rs b/crates/modules/nahual/libs/theme/src/lib.rs index 87e18f3..5b6f17a 100644 --- a/crates/modules/nahual/libs/theme/src/lib.rs +++ b/crates/modules/nahual/libs/theme/src/lib.rs @@ -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 { - 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 { + std::env::var("XDG_CONFIG_HOME") .ok() .filter(|s| !s.is_empty()) .map(PathBuf::from) @@ -493,8 +507,7 @@ pub fn config_path() -> Option { .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 diff --git a/crates/modules/nahual/libs/theme/src/toolkit.rs b/crates/modules/nahual/libs/theme/src/toolkit.rs new file mode 100644 index 0000000..f1c68cc --- /dev/null +++ b/crates/modules/nahual/libs/theme/src/toolkit.rs @@ -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, + /// Archivos saltados: existían y no llevan la marca de nahual, así + /// que son del usuario y no se tocan. + pub skipped: Vec, +} + +/// 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 { + 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 { + 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 ;`. +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(>k4).unwrap().contains("Aurora")); + + // Un `gtk.css` ajeno (sin marca) se respeta: no se pisa. + std::fs::write(>k3, "/* 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(>k3).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)); + } +} diff --git a/docs/changelog/mirada.md b/docs/changelog/mirada.md index d4876c0..b60ea44 100644 --- a/docs/changelog/mirada.md +++ b/docs/changelog/mirada.md @@ -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` diff --git a/docs/changelog/nahual.md b/docs/changelog/nahual.md index 5a4895b..34d81d4 100644 --- a/docs/changelog/nahual.md +++ b/docs/changelog/nahual.md @@ -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