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:
@@ -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(>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));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user