gioser

tawasuyu/brahman

Rust 483TOML 191Markdown 50JSON 176777 Definitions

⎇ ce424eb6af777cc52624f031529c0744ce54cd8c crates/modules/ui_engine/libs/theme/src/lib.rs Rust · 549 lines · 20.8 KB History Who touched what raw

feat(yahweh-theme): persistencia de la preferencia de theme entre runs ce424eb6a · Sergio ·

1//! `yahweh_theme` — paleta de colores y backgrounds compartidos.
2//!
3//! El `Theme` se instala como `Global` de GPUI. Los widgets lo leen vía
4//! `cx.global::<Theme>()` durante su `render`, y se subscriben con
5//! `cx.observe_global::<Theme>(…)` para auto-redibujarse cuando cambia.
6//!
7//! Filosofía: el theme es **dato puro** (sin lógica de UI). No conoce a
8//! ningún widget concreto. Cada widget pide slots semánticos (panel_bg,
9//! row_hover, accent…) sin acoplarse a colores hex específicos.
10
11use gpui::{Background, Global, Hsla, hsla, linear_color_stop, linear_gradient};
12
13/// Paleta semántica del theme. Cada slot tiene un nombre funcional, no
14/// cromático — así los widgets piden "fondo de panel" sin acoplarse a
15/// "azul oscuro".
16///
17/// Convención de slots:
18/// - `bg_*` se devuelve como `Background` (soporta gradientes); los widgets
19///   lo pasan a `.bg(...)` directamente.
20/// - `fg_*`, `accent`, `border` son `Hsla` (colores planos para texto y
21///   ornamentos).
22/// - `bg_row_*` son `Hsla` porque las filas de una lista virtualizada se
23///   beneficiarían poco de un gradiente individual.
24#[derive(Clone, Debug)]
25pub struct Theme {
26    pub name: &'static str,
27    pub is_dark: bool,
28
29    // Fondos.
30    pub bg_app: Background,
31    pub bg_panel: Background,
32    pub bg_panel_alt: Background,
33    pub bg_row_hover: Hsla,
34    pub bg_row_active: Hsla,
35
36    // Foregrounds.
37    pub fg_text: Hsla,
38    pub fg_muted: Hsla,
39    pub fg_disabled: Hsla,
40
41    // Acentos y ornamentos.
42    pub accent: Hsla,
43    pub accent_strong: Hsla,
44    pub border: Hsla,
45    pub border_strong: Hsla,
46
47    /// Marker colors para indicar "este file está abierto en container N".
48    /// Paleta circular — el N-ésimo container usa `marker_palette[n % len]`.
49    pub marker_palette: Vec<Hsla>,
50}
51
52impl Global for Theme {}
53
54/// Helper privado: deriva los 5 slots "ornament secundario"
55/// (bg_input/button/button_hover + accent_destructive +
56/// bg_destructive_hover) según `is_dark`.
57///
58/// Devuelve los slots en el orden de los métodos públicos del
59/// `Theme`. Los métodos del impl los exponen individualmente.
60fn ornament_slots(is_dark: bool) -> (Hsla, Hsla, Hsla, Hsla, Hsla) {
61    if is_dark {
62        (
63            // bg_input: muy oscuro, sutil tinte azul/gris
64            hsla(220.0 / 360.0, 0.20, 0.07, 1.0),
65            // bg_button: medio oscuro
66            hsla(220.0 / 360.0, 0.18, 0.20, 1.0),
67            // bg_button_hover: un poco más claro
68            hsla(220.0 / 360.0, 0.20, 0.27, 1.0),
69            // accent_destructive: rojo medio-claro para visibilidad
70            hsla(0.0, 0.55, 0.65, 1.0),
71            // bg_destructive_hover: rojo oscuro de fondo
72            hsla(0.0, 0.55, 0.18, 1.0),
73        )
74    } else {
75        (
76            hsla(220.0 / 360.0, 0.10, 0.97, 1.0),
77            hsla(220.0 / 360.0, 0.15, 0.85, 1.0),
78            hsla(220.0 / 360.0, 0.20, 0.75, 1.0),
79            hsla(0.0, 0.65, 0.45, 1.0),
80            hsla(0.0, 0.55, 0.92, 1.0),
81        )
82    }
83}
84
85impl Theme {
86    /// Bg sutil para fields editables que se quieren marcar como
87    /// "input target" sin ser un panel. Derivado de `is_dark`.
88    pub fn bg_input(&self) -> Hsla {
89        ornament_slots(self.is_dark).0
90    }
91
92    /// Bg para clickable controls (botones secundarios, edit/delete
93    /// icons en filas). Más prominente que `bg_panel_alt`, menos que
94    /// `accent`. Derivado de `is_dark`.
95    pub fn bg_button(&self) -> Hsla {
96        ornament_slots(self.is_dark).1
97    }
98
99    /// Hover de [`Self::bg_button`].
100    pub fn bg_button_hover(&self) -> Hsla {
101        ornament_slots(self.is_dark).2
102    }
103
104    /// Accent rojo para acciones destructivas (delete, drop, force).
105    pub fn accent_destructive(&self) -> Hsla {
106        ornament_slots(self.is_dark).3
107    }
108
109    /// Bg de hover sobre clickable destructive elements (icon ✕,
110    /// botones de "borrar"). Más oscuro que `accent_destructive`.
111    pub fn bg_destructive_hover(&self) -> Hsla {
112        ornament_slots(self.is_dark).4
113    }
114
115    pub fn global(cx: &gpui::App) -> &Self {
116        cx.global::<Self>()
117    }
118
119    /// Carga el theme persistido si existe + es válido; sino default
120    /// a Nebula. El persisted lo escribe [`Self::set`] cada vez que el
121    /// theme cambia (típicamente vía `theme_switcher`).
122    pub fn install_default(cx: &mut gpui::App) {
123        let theme = load_persisted().unwrap_or_else(Self::nebula);
124        cx.set_global(theme);
125    }
126
127    /// Reemplaza el theme global y persiste su `name` al config file.
128    /// GPUI notifica a todos los `observe_global` suscriptores en el
129    /// siguiente frame.
130    ///
131    /// La persistencia es best-effort: si write falla (no hay home,
132    /// permission denied, etc.), el theme cambia in-memory pero no
133    /// sobrevive al restart. No se rebota — la UX no se interrumpe
134    /// por un I/O secundario.
135    pub fn set(cx: &mut gpui::App, theme: Self) {
136        let _ = persist(&theme);
137        cx.set_global(theme);
138    }
139
140    /// Lista todos los presets en orden estable. Usado por el switcher para
141    /// ciclar y por el menú de "Tema" cuando lo agreguemos.
142    pub fn all() -> Vec<Self> {
143        vec![
144            Self::nebula(),
145            Self::aurora(),
146            Self::sunset(),
147            Self::flat_dark(),
148            Self::solarized_light(),
149            Self::high_contrast(),
150        ]
151    }
152
153    /// Devuelve el preset cuyo `name` matchea (case-insensitive). `None` si
154    /// el nombre no existe — útil para validar input de usuario al cargar
155    /// preferencias persistidas.
156    pub fn by_name(name: &str) -> Option<Self> {
157        Self::all()
158            .into_iter()
159            .find(|t| t.name.eq_ignore_ascii_case(name))
160    }
161
162    /// Próximo preset en la rotación de `all()`. Si `current` no está, se
163    /// vuelve al primero. La rotación es circular (último → primero).
164    pub fn next_after(current: &str) -> Self {
165        let all = Self::all();
166        let idx = all.iter().position(|t| t.name == current);
167        match idx {
168            Some(i) => all[(i + 1) % all.len()].clone(),
169            None => all[0].clone(),
170        }
171    }
172
173    // =====================================================================
174    // Presets
175    // =====================================================================
176
177    /// **Nebula** — default. Gradiente vertical violáceo profundo → teal
178    /// medianoche. Pensado para sentirse moderno y descansado de noche.
179    pub fn nebula() -> Self {
180        let bg_app = linear_gradient(
181            165.0,
182            linear_color_stop(hsla(265.0 / 360.0, 0.38, 0.07, 1.0), 0.0),
183            linear_color_stop(hsla(195.0 / 360.0, 0.42, 0.09, 1.0), 1.0),
184        );
185        let bg_panel = linear_gradient(
186            165.0,
187            linear_color_stop(hsla(245.0 / 360.0, 0.28, 0.10, 1.0), 0.0),
188            linear_color_stop(hsla(210.0 / 360.0, 0.30, 0.12, 1.0), 1.0),
189        );
190        let bg_panel_alt = linear_gradient(
191            165.0,
192            linear_color_stop(hsla(255.0 / 360.0, 0.25, 0.13, 1.0), 0.0),
193            linear_color_stop(hsla(220.0 / 360.0, 0.27, 0.14, 1.0), 1.0),
194        );
195
196        Self {
197            name: "Nebula",
198            is_dark: true,
199            bg_app,
200            bg_panel,
201            bg_panel_alt,
202            bg_row_hover: hsla(220.0 / 360.0, 0.30, 0.20, 0.45),
203            bg_row_active: hsla(280.0 / 360.0, 0.55, 0.28, 0.65),
204            fg_text: hsla(210.0 / 360.0, 0.35, 0.88, 1.0),
205            fg_muted: hsla(215.0 / 360.0, 0.22, 0.58, 1.0),
206            fg_disabled: hsla(215.0 / 360.0, 0.10, 0.40, 1.0),
207            accent: hsla(280.0 / 360.0, 0.65, 0.65, 1.0),
208            accent_strong: hsla(285.0 / 360.0, 0.78, 0.74, 1.0),
209            border: hsla(225.0 / 360.0, 0.20, 0.22, 1.0),
210            border_strong: hsla(280.0 / 360.0, 0.40, 0.45, 1.0),
211            marker_palette: vec![
212                hsla(280.0 / 360.0, 0.65, 0.55, 0.45),
213                hsla(195.0 / 360.0, 0.65, 0.50, 0.45),
214                hsla(35.0 / 360.0, 0.75, 0.55, 0.45),
215                hsla(135.0 / 360.0, 0.55, 0.50, 0.45),
216                hsla(0.0, 0.60, 0.55, 0.45),
217            ],
218        }
219    }
220
221    /// **Aurora** — verdes-cian-azul, evoca aurora boreal. Más frío que
222    /// Nebula, contraste alto.
223    pub fn aurora() -> Self {
224        let bg_app = linear_gradient(
225            190.0,
226            linear_color_stop(hsla(170.0 / 360.0, 0.45, 0.06, 1.0), 0.0),
227            linear_color_stop(hsla(220.0 / 360.0, 0.50, 0.09, 1.0), 1.0),
228        );
229        let bg_panel = linear_gradient(
230            190.0,
231            linear_color_stop(hsla(165.0 / 360.0, 0.32, 0.10, 1.0), 0.0),
232            linear_color_stop(hsla(215.0 / 360.0, 0.36, 0.12, 1.0), 1.0),
233        );
234        let bg_panel_alt = linear_gradient(
235            190.0,
236            linear_color_stop(hsla(170.0 / 360.0, 0.30, 0.13, 1.0), 0.0),
237            linear_color_stop(hsla(220.0 / 360.0, 0.32, 0.15, 1.0), 1.0),
238        );
239
240        Self {
241            name: "Aurora",
242            is_dark: true,
243            bg_app,
244            bg_panel,
245            bg_panel_alt,
246            bg_row_hover: hsla(180.0 / 360.0, 0.40, 0.22, 0.50),
247            bg_row_active: hsla(160.0 / 360.0, 0.55, 0.30, 0.65),
248            fg_text: hsla(180.0 / 360.0, 0.20, 0.92, 1.0),
249            fg_muted: hsla(185.0 / 360.0, 0.18, 0.62, 1.0),
250            fg_disabled: hsla(185.0 / 360.0, 0.10, 0.40, 1.0),
251            accent: hsla(150.0 / 360.0, 0.70, 0.55, 1.0),
252            accent_strong: hsla(160.0 / 360.0, 0.85, 0.65, 1.0),
253            border: hsla(195.0 / 360.0, 0.25, 0.20, 1.0),
254            border_strong: hsla(160.0 / 360.0, 0.55, 0.45, 1.0),
255            marker_palette: vec![
256                hsla(150.0 / 360.0, 0.75, 0.50, 0.45),
257                hsla(195.0 / 360.0, 0.70, 0.50, 0.45),
258                hsla(225.0 / 360.0, 0.70, 0.55, 0.45),
259                hsla(85.0 / 360.0, 0.65, 0.50, 0.45),
260                hsla(330.0 / 360.0, 0.65, 0.55, 0.45),
261            ],
262        }
263    }
264
265    /// **Sunset** — naranjas-rosas-violetas profundos. Cálido, alto contraste
266    /// con texto claro.
267    pub fn sunset() -> Self {
268        let bg_app = linear_gradient(
269            170.0,
270            linear_color_stop(hsla(20.0 / 360.0, 0.50, 0.08, 1.0), 0.0),
271            linear_color_stop(hsla(310.0 / 360.0, 0.45, 0.10, 1.0), 1.0),
272        );
273        let bg_panel = linear_gradient(
274            170.0,
275            linear_color_stop(hsla(15.0 / 360.0, 0.32, 0.12, 1.0), 0.0),
276            linear_color_stop(hsla(315.0 / 360.0, 0.30, 0.13, 1.0), 1.0),
277        );
278        let bg_panel_alt = linear_gradient(
279            170.0,
280            linear_color_stop(hsla(20.0 / 360.0, 0.30, 0.15, 1.0), 0.0),
281            linear_color_stop(hsla(320.0 / 360.0, 0.28, 0.16, 1.0), 1.0),
282        );
283
284        Self {
285            name: "Sunset",
286            is_dark: true,
287            bg_app,
288            bg_panel,
289            bg_panel_alt,
290            bg_row_hover: hsla(25.0 / 360.0, 0.40, 0.25, 0.45),
291            bg_row_active: hsla(5.0 / 360.0, 0.55, 0.32, 0.65),
292            fg_text: hsla(30.0 / 360.0, 0.30, 0.92, 1.0),
293            fg_muted: hsla(25.0 / 360.0, 0.20, 0.62, 1.0),
294            fg_disabled: hsla(25.0 / 360.0, 0.10, 0.42, 1.0),
295            accent: hsla(15.0 / 360.0, 0.78, 0.62, 1.0),
296            accent_strong: hsla(355.0 / 360.0, 0.85, 0.68, 1.0),
297            border: hsla(15.0 / 360.0, 0.25, 0.25, 1.0),
298            border_strong: hsla(355.0 / 360.0, 0.55, 0.45, 1.0),
299            marker_palette: vec![
300                hsla(15.0 / 360.0, 0.80, 0.55, 0.45),
301                hsla(310.0 / 360.0, 0.65, 0.55, 0.45),
302                hsla(45.0 / 360.0, 0.80, 0.55, 0.45),
303                hsla(285.0 / 360.0, 0.65, 0.60, 0.45),
304                hsla(355.0 / 360.0, 0.70, 0.55, 0.45),
305            ],
306        }
307    }
308
309    /// **Flat Dark** — sin gradientes, paleta cool gris-azulado. Para quien
310    /// prefiere monocromía. Útil para contrastar visualmente con los temas
311    /// de gradiente.
312    pub fn flat_dark() -> Self {
313        let bg_app: Background = hsla(220.0 / 360.0, 0.15, 0.09, 1.0).into();
314        let bg_panel: Background = hsla(220.0 / 360.0, 0.15, 0.12, 1.0).into();
315        let bg_panel_alt: Background = hsla(220.0 / 360.0, 0.15, 0.14, 1.0).into();
316        Self {
317            name: "Flat Dark",
318            is_dark: true,
319            bg_app,
320            bg_panel,
321            bg_panel_alt,
322            bg_row_hover: hsla(220.0 / 360.0, 0.20, 0.20, 1.0),
323            bg_row_active: hsla(220.0 / 360.0, 0.40, 0.30, 1.0),
324            fg_text: hsla(210.0 / 360.0, 0.20, 0.85, 1.0),
325            fg_muted: hsla(215.0 / 360.0, 0.15, 0.55, 1.0),
326            fg_disabled: hsla(215.0 / 360.0, 0.10, 0.40, 1.0),
327            accent: hsla(210.0 / 360.0, 0.70, 0.55, 1.0),
328            accent_strong: hsla(210.0 / 360.0, 0.85, 0.65, 1.0),
329            border: hsla(220.0 / 360.0, 0.15, 0.20, 1.0),
330            border_strong: hsla(220.0 / 360.0, 0.30, 0.35, 1.0),
331            marker_palette: vec![
332                hsla(210.0 / 360.0, 0.65, 0.55, 0.40),
333                hsla(160.0 / 360.0, 0.55, 0.50, 0.40),
334                hsla(30.0 / 360.0, 0.75, 0.55, 0.40),
335                hsla(0.0, 0.55, 0.55, 0.40),
336            ],
337        }
338    }
339
340    /// **Solarized Light** — preset claro inspirado en la paleta clásica de
341    /// Schoonover. Sin gradientes (en light un gradiente sutil pasa
342    /// desapercibido y solo introduce ruido).
343    pub fn solarized_light() -> Self {
344        let bg_app: Background = hsla(44.0 / 360.0, 0.87, 0.94, 1.0).into();
345        let bg_panel: Background = hsla(46.0 / 360.0, 0.42, 0.88, 1.0).into();
346        let bg_panel_alt: Background = hsla(46.0 / 360.0, 0.42, 0.92, 1.0).into();
347        Self {
348            name: "Solarized Light",
349            is_dark: false,
350            bg_app,
351            bg_panel,
352            bg_panel_alt,
353            bg_row_hover: hsla(46.0 / 360.0, 0.45, 0.80, 0.65),
354            bg_row_active: hsla(45.0 / 360.0, 0.55, 0.72, 0.85),
355            fg_text: hsla(196.0 / 360.0, 0.13, 0.30, 1.0),
356            fg_muted: hsla(196.0 / 360.0, 0.13, 0.45, 1.0),
357            fg_disabled: hsla(196.0 / 360.0, 0.10, 0.62, 1.0),
358            accent: hsla(205.0 / 360.0, 0.69, 0.42, 1.0),
359            accent_strong: hsla(205.0 / 360.0, 0.82, 0.38, 1.0),
360            border: hsla(46.0 / 360.0, 0.30, 0.78, 1.0),
361            border_strong: hsla(205.0 / 360.0, 0.40, 0.55, 1.0),
362            marker_palette: vec![
363                hsla(205.0 / 360.0, 0.69, 0.42, 0.30),
364                hsla(175.0 / 360.0, 0.74, 0.32, 0.30),
365                hsla(45.0 / 360.0, 1.00, 0.36, 0.30),
366                hsla(331.0 / 360.0, 0.74, 0.42, 0.30),
367                hsla(18.0 / 360.0, 0.89, 0.40, 0.30),
368            ],
369        }
370    }
371
372    /// **High Contrast** — accesibilidad. Negro puro con texto blanco y
373    /// ornamentos amarillo/verde fuertes. Suficientemente diferente para
374    /// notar inmediatamente al usar el switcher.
375    pub fn high_contrast() -> Self {
376        let bg_app: Background = hsla(0.0, 0.0, 0.0, 1.0).into();
377        let bg_panel: Background = hsla(0.0, 0.0, 0.05, 1.0).into();
378        let bg_panel_alt: Background = hsla(0.0, 0.0, 0.10, 1.0).into();
379        Self {
380            name: "High Contrast",
381            is_dark: true,
382            bg_app,
383            bg_panel,
384            bg_panel_alt,
385            bg_row_hover: hsla(60.0 / 360.0, 1.00, 0.50, 0.35),
386            bg_row_active: hsla(120.0 / 360.0, 1.00, 0.40, 0.55),
387            fg_text: hsla(0.0, 0.0, 1.0, 1.0),
388            fg_muted: hsla(0.0, 0.0, 0.75, 1.0),
389            fg_disabled: hsla(0.0, 0.0, 0.50, 1.0),
390            accent: hsla(60.0 / 360.0, 1.00, 0.60, 1.0),
391            accent_strong: hsla(60.0 / 360.0, 1.00, 0.75, 1.0),
392            border: hsla(0.0, 0.0, 0.30, 1.0),
393            border_strong: hsla(60.0 / 360.0, 1.00, 0.60, 1.0),
394            marker_palette: vec![
395                hsla(60.0 / 360.0, 1.00, 0.55, 0.50),
396                hsla(120.0 / 360.0, 1.00, 0.50, 0.50),
397                hsla(180.0 / 360.0, 1.00, 0.55, 0.50),
398                hsla(0.0, 1.00, 0.60, 0.50),
399                hsla(300.0 / 360.0, 1.00, 0.65, 0.50),
400            ],
401        }
402    }
403}
404
405// ============================================================================
406// Persistencia de la preferencia de theme
407// ============================================================================
408
409use std::path::{Path, PathBuf};
410
411const CONFIG_SUBDIR: &str = "yahweh";
412const CONFIG_FILE: &str = "theme";
413
414/// Path al archivo donde se persiste la preferencia de theme.
415///
416/// Convención XDG: `$XDG_CONFIG_HOME/yahweh/theme` si está set;
417/// sino `$HOME/.config/yahweh/theme`. `None` si ni `XDG_CONFIG_HOME`
418/// ni `HOME` están definidos (típicamente en sandboxes / CI).
419pub fn config_path() -> Option<PathBuf> {
420    let base = std::env::var("XDG_CONFIG_HOME")
421        .ok()
422        .filter(|s| !s.is_empty())
423        .map(PathBuf::from)
424        .or_else(|| {
425            std::env::var("HOME")
426                .ok()
427                .filter(|s| !s.is_empty())
428                .map(|h| PathBuf::from(h).join(".config"))
429        })?;
430    Some(base.join(CONFIG_SUBDIR).join(CONFIG_FILE))
431}
432
433/// Lee el theme persistido. `None` si: no hay config dir, file no
434/// existe, lectura falla, o el name guardado no matchea ningún
435/// preset (ej. tema renombrado entre versiones).
436pub fn load_persisted() -> Option<Theme> {
437    let path = config_path()?;
438    load_from_path(&path)
439}
440
441/// Persiste el name del theme al config file. Crea el dir parent si
442/// no existe. Devuelve el `io::Error` para que tests puedan
443/// asertar; los call sites de producción pueden ignorarlo
444/// (best-effort persistence).
445pub fn persist(theme: &Theme) -> std::io::Result<()> {
446    let path = config_path().ok_or_else(|| {
447        std::io::Error::new(
448            std::io::ErrorKind::NotFound,
449            "no se pudo determinar config dir (HOME/XDG_CONFIG_HOME no set)",
450        )
451    })?;
452    persist_to_path(theme, &path)
453}
454
455/// Variante de [`load_persisted`] que toma un path explícito. Útil
456/// para tests + para apps que quieren su propio path
457/// (ej. multi-user single-machine).
458pub fn load_from_path(path: &Path) -> Option<Theme> {
459    let raw = std::fs::read_to_string(path).ok()?;
460    let name = raw.trim();
461    Theme::by_name(name)
462}
463
464/// Variante de [`persist`] que toma un path explícito.
465pub fn persist_to_path(theme: &Theme, path: &Path) -> std::io::Result<()> {
466    if let Some(parent) = path.parent() {
467        std::fs::create_dir_all(parent)?;
468    }
469    std::fs::write(path, theme.name)
470}
471
472#[cfg(test)]
473mod persistence_tests {
474    use super::*;
475
476    fn unique_path(label: &str) -> PathBuf {
477        let mut p = std::env::temp_dir();
478        p.push(format!(
479            "yahweh-theme-test-{}-{}-{}",
480            std::process::id(),
481            std::time::SystemTime::now()
482                .duration_since(std::time::UNIX_EPOCH)
483                .map(|d| d.as_nanos())
484                .unwrap_or(0),
485            label
486        ));
487        p
488    }
489
490    #[test]
491    fn persist_then_load_round_trip() {
492        let path = unique_path("round-trip");
493        let theme = Theme::aurora();
494        persist_to_path(&theme, &path).unwrap();
495        let loaded = load_from_path(&path).expect("load");
496        assert_eq!(loaded.name, "Aurora");
497        let _ = std::fs::remove_file(&path);
498        let _ = std::fs::remove_dir(path.parent().unwrap());
499    }
500
501    #[test]
502    fn load_from_missing_file_returns_none() {
503        let path = unique_path("missing");
504        // path NO existe.
505        assert!(load_from_path(&path).is_none());
506    }
507
508    #[test]
509    fn load_from_unknown_name_returns_none() {
510        let path = unique_path("unknown");
511        std::fs::create_dir_all(path.parent().unwrap()).unwrap();
512        std::fs::write(&path, "DefinitelyNotARealTheme").unwrap();
513        assert!(load_from_path(&path).is_none());
514        let _ = std::fs::remove_file(&path);
515        let _ = std::fs::remove_dir(path.parent().unwrap());
516    }
517
518    #[test]
519    fn persist_creates_parent_dir_if_missing() {
520        let path = unique_path("nested-create");
521        // Aseguramos que el parent NO existe antes.
522        let _ = std::fs::remove_dir_all(path.parent().unwrap());
523        persist_to_path(&Theme::sunset(), &path).unwrap();
524        assert!(path.exists());
525        let loaded = load_from_path(&path).unwrap();
526        assert_eq!(loaded.name, "Sunset");
527        let _ = std::fs::remove_file(&path);
528        let _ = std::fs::remove_dir(path.parent().unwrap());
529    }
530
531    #[test]
532    fn config_path_uses_xdg_config_home_when_set() {
533        // Snapshot del env, mutación local, restauración.
534        let prev = std::env::var("XDG_CONFIG_HOME").ok();
535        // SAFETY: tests del crate single-thread por default; este
536        // env mutation no impacta otros tests del mismo proceso.
537        unsafe {
538            std::env::set_var("XDG_CONFIG_HOME", "/custom/xdg");
539        }
540        let p = config_path().unwrap();
541        unsafe {
542            match prev {
543                Some(v) => std::env::set_var("XDG_CONFIG_HOME", v),
544                None => std::env::remove_var("XDG_CONFIG_HOME"),
545            }
546        }
547        assert_eq!(p, PathBuf::from("/custom/xdg/yahweh/theme"));
548    }
549}