⎇ 1078e433f24ac83e2540343e82d2bd0fe49e304a
crates/modules/ui_engine/libs/theme/src/lib.rs
Ñawpa willay
Pi imata llamkarqan
ch'uya
feat(tahuantinsuyu): rueda 3D, hover-highlight, universo, themes papel
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}