tawasuyu

tawasuyu/brahman

Rust 492TOML 194Markdown 53JSON 176939 Sutichaykuna

⎇ 1078e433f24ac83e2540343e82d2bd0fe49e304a crates/modules/ui_engine/libs/theme/src/lib.rs Rust · 616 siq'ikuna · 23.7 KB Ñawpa willay Pi imata llamkarqan ch'uya

feat(tahuantinsuyu): rueda 3D, hover-highlight, universo, themes papel 1078e433f · 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            Self::print_color(),
151            Self::print_bw(),
152        ]
153    }
154
155    /// Devuelve el preset cuyo `name` matchea (case-insensitive). `None` si
156    /// el nombre no existe — útil para validar input de usuario al cargar
157    /// preferencias persistidas.
158    pub fn by_name(name: &str) -> Option<Self> {
159        Self::all()
160            .into_iter()
161            .find(|t| t.name.eq_ignore_ascii_case(name))
162    }
163
164    /// Próximo preset en la rotación de `all()`. Si `current` no está, se
165    /// vuelve al primero. La rotación es circular (último → primero).
166    pub fn next_after(current: &str) -> Self {
167        let all = Self::all();
168        let idx = all.iter().position(|t| t.name == current);
169        match idx {
170            Some(i) => all[(i + 1) % all.len()].clone(),
171            None => all[0].clone(),
172        }
173    }
174
175    // =====================================================================
176    // Presets
177    // =====================================================================
178
179    /// **Nebula** — default. Gradiente vertical violáceo profundo → teal
180    /// medianoche. Pensado para sentirse moderno y descansado de noche.
181    pub fn nebula() -> Self {
182        let bg_app = linear_gradient(
183            165.0,
184            linear_color_stop(hsla(265.0 / 360.0, 0.38, 0.07, 1.0), 0.0),
185            linear_color_stop(hsla(195.0 / 360.0, 0.42, 0.09, 1.0), 1.0),
186        );
187        let bg_panel = linear_gradient(
188            165.0,
189            linear_color_stop(hsla(245.0 / 360.0, 0.28, 0.10, 1.0), 0.0),
190            linear_color_stop(hsla(210.0 / 360.0, 0.30, 0.12, 1.0), 1.0),
191        );
192        let bg_panel_alt = linear_gradient(
193            165.0,
194            linear_color_stop(hsla(255.0 / 360.0, 0.25, 0.13, 1.0), 0.0),
195            linear_color_stop(hsla(220.0 / 360.0, 0.27, 0.14, 1.0), 1.0),
196        );
197
198        Self {
199            name: "Nebula",
200            is_dark: true,
201            bg_app,
202            bg_panel,
203            bg_panel_alt,
204            bg_row_hover: hsla(220.0 / 360.0, 0.30, 0.20, 0.45),
205            bg_row_active: hsla(280.0 / 360.0, 0.55, 0.28, 0.65),
206            fg_text: hsla(210.0 / 360.0, 0.35, 0.88, 1.0),
207            fg_muted: hsla(215.0 / 360.0, 0.22, 0.58, 1.0),
208            fg_disabled: hsla(215.0 / 360.0, 0.10, 0.40, 1.0),
209            accent: hsla(280.0 / 360.0, 0.65, 0.65, 1.0),
210            accent_strong: hsla(285.0 / 360.0, 0.78, 0.74, 1.0),
211            border: hsla(225.0 / 360.0, 0.20, 0.22, 1.0),
212            border_strong: hsla(280.0 / 360.0, 0.40, 0.45, 1.0),
213            marker_palette: vec![
214                hsla(280.0 / 360.0, 0.65, 0.55, 0.45),
215                hsla(195.0 / 360.0, 0.65, 0.50, 0.45),
216                hsla(35.0 / 360.0, 0.75, 0.55, 0.45),
217                hsla(135.0 / 360.0, 0.55, 0.50, 0.45),
218                hsla(0.0, 0.60, 0.55, 0.45),
219            ],
220        }
221    }
222
223    /// **Aurora** — verdes-cian-azul, evoca aurora boreal. Más frío que
224    /// Nebula, contraste alto.
225    pub fn aurora() -> Self {
226        let bg_app = linear_gradient(
227            190.0,
228            linear_color_stop(hsla(170.0 / 360.0, 0.45, 0.06, 1.0), 0.0),
229            linear_color_stop(hsla(220.0 / 360.0, 0.50, 0.09, 1.0), 1.0),
230        );
231        let bg_panel = linear_gradient(
232            190.0,
233            linear_color_stop(hsla(165.0 / 360.0, 0.32, 0.10, 1.0), 0.0),
234            linear_color_stop(hsla(215.0 / 360.0, 0.36, 0.12, 1.0), 1.0),
235        );
236        let bg_panel_alt = linear_gradient(
237            190.0,
238            linear_color_stop(hsla(170.0 / 360.0, 0.30, 0.13, 1.0), 0.0),
239            linear_color_stop(hsla(220.0 / 360.0, 0.32, 0.15, 1.0), 1.0),
240        );
241
242        Self {
243            name: "Aurora",
244            is_dark: true,
245            bg_app,
246            bg_panel,
247            bg_panel_alt,
248            bg_row_hover: hsla(180.0 / 360.0, 0.40, 0.22, 0.50),
249            bg_row_active: hsla(160.0 / 360.0, 0.55, 0.30, 0.65),
250            fg_text: hsla(180.0 / 360.0, 0.20, 0.92, 1.0),
251            fg_muted: hsla(185.0 / 360.0, 0.18, 0.62, 1.0),
252            fg_disabled: hsla(185.0 / 360.0, 0.10, 0.40, 1.0),
253            accent: hsla(150.0 / 360.0, 0.70, 0.55, 1.0),
254            accent_strong: hsla(160.0 / 360.0, 0.85, 0.65, 1.0),
255            border: hsla(195.0 / 360.0, 0.25, 0.20, 1.0),
256            border_strong: hsla(160.0 / 360.0, 0.55, 0.45, 1.0),
257            marker_palette: vec![
258                hsla(150.0 / 360.0, 0.75, 0.50, 0.45),
259                hsla(195.0 / 360.0, 0.70, 0.50, 0.45),
260                hsla(225.0 / 360.0, 0.70, 0.55, 0.45),
261                hsla(85.0 / 360.0, 0.65, 0.50, 0.45),
262                hsla(330.0 / 360.0, 0.65, 0.55, 0.45),
263            ],
264        }
265    }
266
267    /// **Sunset** — naranjas-rosas-violetas profundos. Cálido, alto contraste
268    /// con texto claro.
269    pub fn sunset() -> Self {
270        let bg_app = linear_gradient(
271            170.0,
272            linear_color_stop(hsla(20.0 / 360.0, 0.50, 0.08, 1.0), 0.0),
273            linear_color_stop(hsla(310.0 / 360.0, 0.45, 0.10, 1.0), 1.0),
274        );
275        let bg_panel = linear_gradient(
276            170.0,
277            linear_color_stop(hsla(15.0 / 360.0, 0.32, 0.12, 1.0), 0.0),
278            linear_color_stop(hsla(315.0 / 360.0, 0.30, 0.13, 1.0), 1.0),
279        );
280        let bg_panel_alt = linear_gradient(
281            170.0,
282            linear_color_stop(hsla(20.0 / 360.0, 0.30, 0.15, 1.0), 0.0),
283            linear_color_stop(hsla(320.0 / 360.0, 0.28, 0.16, 1.0), 1.0),
284        );
285
286        Self {
287            name: "Sunset",
288            is_dark: true,
289            bg_app,
290            bg_panel,
291            bg_panel_alt,
292            bg_row_hover: hsla(25.0 / 360.0, 0.40, 0.25, 0.45),
293            bg_row_active: hsla(5.0 / 360.0, 0.55, 0.32, 0.65),
294            fg_text: hsla(30.0 / 360.0, 0.30, 0.92, 1.0),
295            fg_muted: hsla(25.0 / 360.0, 0.20, 0.62, 1.0),
296            fg_disabled: hsla(25.0 / 360.0, 0.10, 0.42, 1.0),
297            accent: hsla(15.0 / 360.0, 0.78, 0.62, 1.0),
298            accent_strong: hsla(355.0 / 360.0, 0.85, 0.68, 1.0),
299            border: hsla(15.0 / 360.0, 0.25, 0.25, 1.0),
300            border_strong: hsla(355.0 / 360.0, 0.55, 0.45, 1.0),
301            marker_palette: vec![
302                hsla(15.0 / 360.0, 0.80, 0.55, 0.45),
303                hsla(310.0 / 360.0, 0.65, 0.55, 0.45),
304                hsla(45.0 / 360.0, 0.80, 0.55, 0.45),
305                hsla(285.0 / 360.0, 0.65, 0.60, 0.45),
306                hsla(355.0 / 360.0, 0.70, 0.55, 0.45),
307            ],
308        }
309    }
310
311    /// **Flat Dark** — sin gradientes, paleta cool gris-azulado. Para quien
312    /// prefiere monocromía. Útil para contrastar visualmente con los temas
313    /// de gradiente.
314    pub fn flat_dark() -> Self {
315        let bg_app: Background = hsla(220.0 / 360.0, 0.15, 0.09, 1.0).into();
316        let bg_panel: Background = hsla(220.0 / 360.0, 0.15, 0.12, 1.0).into();
317        let bg_panel_alt: Background = hsla(220.0 / 360.0, 0.15, 0.14, 1.0).into();
318        Self {
319            name: "Flat Dark",
320            is_dark: true,
321            bg_app,
322            bg_panel,
323            bg_panel_alt,
324            bg_row_hover: hsla(220.0 / 360.0, 0.20, 0.20, 1.0),
325            bg_row_active: hsla(220.0 / 360.0, 0.40, 0.30, 1.0),
326            fg_text: hsla(210.0 / 360.0, 0.20, 0.85, 1.0),
327            fg_muted: hsla(215.0 / 360.0, 0.15, 0.55, 1.0),
328            fg_disabled: hsla(215.0 / 360.0, 0.10, 0.40, 1.0),
329            accent: hsla(210.0 / 360.0, 0.70, 0.55, 1.0),
330            accent_strong: hsla(210.0 / 360.0, 0.85, 0.65, 1.0),
331            border: hsla(220.0 / 360.0, 0.15, 0.20, 1.0),
332            border_strong: hsla(220.0 / 360.0, 0.30, 0.35, 1.0),
333            marker_palette: vec![
334                hsla(210.0 / 360.0, 0.65, 0.55, 0.40),
335                hsla(160.0 / 360.0, 0.55, 0.50, 0.40),
336                hsla(30.0 / 360.0, 0.75, 0.55, 0.40),
337                hsla(0.0, 0.55, 0.55, 0.40),
338            ],
339        }
340    }
341
342    /// **Solarized Light** — preset claro inspirado en la paleta clásica de
343    /// Schoonover. Sin gradientes (en light un gradiente sutil pasa
344    /// desapercibido y solo introduce ruido).
345    pub fn solarized_light() -> Self {
346        let bg_app: Background = hsla(44.0 / 360.0, 0.87, 0.94, 1.0).into();
347        let bg_panel: Background = hsla(46.0 / 360.0, 0.42, 0.88, 1.0).into();
348        let bg_panel_alt: Background = hsla(46.0 / 360.0, 0.42, 0.92, 1.0).into();
349        Self {
350            name: "Solarized Light",
351            is_dark: false,
352            bg_app,
353            bg_panel,
354            bg_panel_alt,
355            bg_row_hover: hsla(46.0 / 360.0, 0.45, 0.80, 0.65),
356            bg_row_active: hsla(45.0 / 360.0, 0.55, 0.72, 0.85),
357            fg_text: hsla(196.0 / 360.0, 0.13, 0.30, 1.0),
358            fg_muted: hsla(196.0 / 360.0, 0.13, 0.45, 1.0),
359            fg_disabled: hsla(196.0 / 360.0, 0.10, 0.62, 1.0),
360            accent: hsla(205.0 / 360.0, 0.69, 0.42, 1.0),
361            accent_strong: hsla(205.0 / 360.0, 0.82, 0.38, 1.0),
362            border: hsla(46.0 / 360.0, 0.30, 0.78, 1.0),
363            border_strong: hsla(205.0 / 360.0, 0.40, 0.55, 1.0),
364            marker_palette: vec![
365                hsla(205.0 / 360.0, 0.69, 0.42, 0.30),
366                hsla(175.0 / 360.0, 0.74, 0.32, 0.30),
367                hsla(45.0 / 360.0, 1.00, 0.36, 0.30),
368                hsla(331.0 / 360.0, 0.74, 0.42, 0.30),
369                hsla(18.0 / 360.0, 0.89, 0.40, 0.30),
370            ],
371        }
372    }
373
374    /// **Print Color** — preview de impresión a color sobre papel.
375    /// Fondo crema cálido (#f7f4ea-ish), texto y ornamentos en
376    /// luminancias bajas para que sobrevivan ink-bleed. Sin gradientes
377    /// (los gradients no imprimen bien) y sin glow.
378    pub fn print_color() -> Self {
379        let bg_app: Background = hsla(42.0 / 360.0, 0.30, 0.94, 1.0).into();
380        let bg_panel: Background = hsla(40.0 / 360.0, 0.25, 0.97, 1.0).into();
381        let bg_panel_alt: Background = hsla(40.0 / 360.0, 0.20, 0.92, 1.0).into();
382        Self {
383            name: "Print Color",
384            is_dark: false,
385            bg_app,
386            bg_panel,
387            bg_panel_alt,
388            bg_row_hover: hsla(40.0 / 360.0, 0.30, 0.86, 0.70),
389            bg_row_active: hsla(35.0 / 360.0, 0.45, 0.78, 0.85),
390            fg_text: hsla(30.0 / 360.0, 0.15, 0.18, 1.0),
391            fg_muted: hsla(30.0 / 360.0, 0.12, 0.40, 1.0),
392            fg_disabled: hsla(30.0 / 360.0, 0.08, 0.62, 1.0),
393            accent: hsla(15.0 / 360.0, 0.70, 0.40, 1.0),
394            accent_strong: hsla(355.0 / 360.0, 0.78, 0.36, 1.0),
395            border: hsla(40.0 / 360.0, 0.22, 0.82, 1.0),
396            border_strong: hsla(30.0 / 360.0, 0.30, 0.55, 1.0),
397            marker_palette: vec![
398                hsla(15.0 / 360.0, 0.70, 0.35, 0.30),
399                hsla(210.0 / 360.0, 0.65, 0.35, 0.30),
400                hsla(140.0 / 360.0, 0.55, 0.30, 0.30),
401                hsla(285.0 / 360.0, 0.55, 0.38, 0.30),
402                hsla(40.0 / 360.0, 0.85, 0.40, 0.30),
403            ],
404        }
405    }
406
407    /// **Print B&W** — preview de impresión monocromática. Fondo
408    /// blanco puro, todo en escala de grises. Cualquier slot que
409    /// dependa de "color" en widgets astrológicos se diferencia por
410    /// forma o por dash pattern, no por tinte.
411    pub fn print_bw() -> Self {
412        let bg_app: Background = hsla(0.0, 0.0, 1.00, 1.0).into();
413        let bg_panel: Background = hsla(0.0, 0.0, 0.99, 1.0).into();
414        let bg_panel_alt: Background = hsla(0.0, 0.0, 0.95, 1.0).into();
415        Self {
416            name: "Print B&W",
417            is_dark: false,
418            bg_app,
419            bg_panel,
420            bg_panel_alt,
421            bg_row_hover: hsla(0.0, 0.0, 0.88, 0.85),
422            bg_row_active: hsla(0.0, 0.0, 0.78, 0.95),
423            fg_text: hsla(0.0, 0.0, 0.10, 1.0),
424            fg_muted: hsla(0.0, 0.0, 0.40, 1.0),
425            fg_disabled: hsla(0.0, 0.0, 0.65, 1.0),
426            accent: hsla(0.0, 0.0, 0.20, 1.0),
427            accent_strong: hsla(0.0, 0.0, 0.05, 1.0),
428            border: hsla(0.0, 0.0, 0.80, 1.0),
429            border_strong: hsla(0.0, 0.0, 0.40, 1.0),
430            marker_palette: vec![
431                hsla(0.0, 0.0, 0.30, 0.35),
432                hsla(0.0, 0.0, 0.50, 0.35),
433                hsla(0.0, 0.0, 0.20, 0.35),
434                hsla(0.0, 0.0, 0.60, 0.35),
435            ],
436        }
437    }
438
439    /// **High Contrast** — accesibilidad. Negro puro con texto blanco y
440    /// ornamentos amarillo/verde fuertes. Suficientemente diferente para
441    /// notar inmediatamente al usar el switcher.
442    pub fn high_contrast() -> Self {
443        let bg_app: Background = hsla(0.0, 0.0, 0.0, 1.0).into();
444        let bg_panel: Background = hsla(0.0, 0.0, 0.05, 1.0).into();
445        let bg_panel_alt: Background = hsla(0.0, 0.0, 0.10, 1.0).into();
446        Self {
447            name: "High Contrast",
448            is_dark: true,
449            bg_app,
450            bg_panel,
451            bg_panel_alt,
452            bg_row_hover: hsla(60.0 / 360.0, 1.00, 0.50, 0.35),
453            bg_row_active: hsla(120.0 / 360.0, 1.00, 0.40, 0.55),
454            fg_text: hsla(0.0, 0.0, 1.0, 1.0),
455            fg_muted: hsla(0.0, 0.0, 0.75, 1.0),
456            fg_disabled: hsla(0.0, 0.0, 0.50, 1.0),
457            accent: hsla(60.0 / 360.0, 1.00, 0.60, 1.0),
458            accent_strong: hsla(60.0 / 360.0, 1.00, 0.75, 1.0),
459            border: hsla(0.0, 0.0, 0.30, 1.0),
460            border_strong: hsla(60.0 / 360.0, 1.00, 0.60, 1.0),
461            marker_palette: vec![
462                hsla(60.0 / 360.0, 1.00, 0.55, 0.50),
463                hsla(120.0 / 360.0, 1.00, 0.50, 0.50),
464                hsla(180.0 / 360.0, 1.00, 0.55, 0.50),
465                hsla(0.0, 1.00, 0.60, 0.50),
466                hsla(300.0 / 360.0, 1.00, 0.65, 0.50),
467            ],
468        }
469    }
470}
471
472// ============================================================================
473// Persistencia de la preferencia de theme
474// ============================================================================
475
476use std::path::{Path, PathBuf};
477
478const CONFIG_SUBDIR: &str = "yahweh";
479const CONFIG_FILE: &str = "theme";
480
481/// Path al archivo donde se persiste la preferencia de theme.
482///
483/// Convención XDG: `$XDG_CONFIG_HOME/yahweh/theme` si está set;
484/// sino `$HOME/.config/yahweh/theme`. `None` si ni `XDG_CONFIG_HOME`
485/// ni `HOME` están definidos (típicamente en sandboxes / CI).
486pub fn config_path() -> Option<PathBuf> {
487    let base = std::env::var("XDG_CONFIG_HOME")
488        .ok()
489        .filter(|s| !s.is_empty())
490        .map(PathBuf::from)
491        .or_else(|| {
492            std::env::var("HOME")
493                .ok()
494                .filter(|s| !s.is_empty())
495                .map(|h| PathBuf::from(h).join(".config"))
496        })?;
497    Some(base.join(CONFIG_SUBDIR).join(CONFIG_FILE))
498}
499
500/// Lee el theme persistido. `None` si: no hay config dir, file no
501/// existe, lectura falla, o el name guardado no matchea ningún
502/// preset (ej. tema renombrado entre versiones).
503pub fn load_persisted() -> Option<Theme> {
504    let path = config_path()?;
505    load_from_path(&path)
506}
507
508/// Persiste el name del theme al config file. Crea el dir parent si
509/// no existe. Devuelve el `io::Error` para que tests puedan
510/// asertar; los call sites de producción pueden ignorarlo
511/// (best-effort persistence).
512pub fn persist(theme: &Theme) -> std::io::Result<()> {
513    let path = config_path().ok_or_else(|| {
514        std::io::Error::new(
515            std::io::ErrorKind::NotFound,
516            "no se pudo determinar config dir (HOME/XDG_CONFIG_HOME no set)",
517        )
518    })?;
519    persist_to_path(theme, &path)
520}
521
522/// Variante de [`load_persisted`] que toma un path explícito. Útil
523/// para tests + para apps que quieren su propio path
524/// (ej. multi-user single-machine).
525pub fn load_from_path(path: &Path) -> Option<Theme> {
526    let raw = std::fs::read_to_string(path).ok()?;
527    let name = raw.trim();
528    Theme::by_name(name)
529}
530
531/// Variante de [`persist`] que toma un path explícito.
532pub fn persist_to_path(theme: &Theme, path: &Path) -> std::io::Result<()> {
533    if let Some(parent) = path.parent() {
534        std::fs::create_dir_all(parent)?;
535    }
536    std::fs::write(path, theme.name)
537}
538
539#[cfg(test)]
540mod persistence_tests {
541    use super::*;
542
543    fn unique_path(label: &str) -> PathBuf {
544        let mut p = std::env::temp_dir();
545        p.push(format!(
546            "yahweh-theme-test-{}-{}-{}",
547            std::process::id(),
548            std::time::SystemTime::now()
549                .duration_since(std::time::UNIX_EPOCH)
550                .map(|d| d.as_nanos())
551                .unwrap_or(0),
552            label
553        ));
554        p
555    }
556
557    #[test]
558    fn persist_then_load_round_trip() {
559        let path = unique_path("round-trip");
560        let theme = Theme::aurora();
561        persist_to_path(&theme, &path).unwrap();
562        let loaded = load_from_path(&path).expect("load");
563        assert_eq!(loaded.name, "Aurora");
564        let _ = std::fs::remove_file(&path);
565        let _ = std::fs::remove_dir(path.parent().unwrap());
566    }
567
568    #[test]
569    fn load_from_missing_file_returns_none() {
570        let path = unique_path("missing");
571        // path NO existe.
572        assert!(load_from_path(&path).is_none());
573    }
574
575    #[test]
576    fn load_from_unknown_name_returns_none() {
577        let path = unique_path("unknown");
578        std::fs::create_dir_all(path.parent().unwrap()).unwrap();
579        std::fs::write(&path, "DefinitelyNotARealTheme").unwrap();
580        assert!(load_from_path(&path).is_none());
581        let _ = std::fs::remove_file(&path);
582        let _ = std::fs::remove_dir(path.parent().unwrap());
583    }
584
585    #[test]
586    fn persist_creates_parent_dir_if_missing() {
587        let path = unique_path("nested-create");
588        // Aseguramos que el parent NO existe antes.
589        let _ = std::fs::remove_dir_all(path.parent().unwrap());
590        persist_to_path(&Theme::sunset(), &path).unwrap();
591        assert!(path.exists());
592        let loaded = load_from_path(&path).unwrap();
593        assert_eq!(loaded.name, "Sunset");
594        let _ = std::fs::remove_file(&path);
595        let _ = std::fs::remove_dir(path.parent().unwrap());
596    }
597
598    #[test]
599    fn config_path_uses_xdg_config_home_when_set() {
600        // Snapshot del env, mutación local, restauración.
601        let prev = std::env::var("XDG_CONFIG_HOME").ok();
602        // SAFETY: tests del crate single-thread por default; este
603        // env mutation no impacta otros tests del mismo proceso.
604        unsafe {
605            std::env::set_var("XDG_CONFIG_HOME", "/custom/xdg");
606        }
607        let p = config_path().unwrap();
608        unsafe {
609            match prev {
610                Some(v) => std::env::set_var("XDG_CONFIG_HOME", v),
611                None => std::env::remove_var("XDG_CONFIG_HOME"),
612            }
613        }
614        assert_eq!(p, PathBuf::from("/custom/xdg/yahweh/theme"));
615    }
616}