⎇ 0ae622550d62cf0f7dda779729f6a55d312c85f1
crates/apps/tahuantinsuyu/src/shell.rs
Ñawpa willay
Pi imata llamkarqan
ch'uya
feat(tahuantinsuyu): fase 17 — filtros de aspectos + editor + cleanup + labels
1//! Shell — coordinador de los tres widgets.
2//!
3//! Es el "director de orquesta": dueño del tree, del canvas y del panel,
4//! reenvía eventos entre ellos y aplica las mutaciones en la store.
5//!
6//! Flujo:
7//!
8//! ```text
9//! Tree.Selected(Chart) → Shell → load chart + compose + set_mode(Wheel)
10//! Tree.Selected(Group/Contact)→ Shell → charts_under_* + set_mode(Thumbnails)
11//! Canvas.TimeOffsetChanged → Shell → compose(current_chart, off, requests)
12//! Canvas.LayerVisibility[T] → Shell → flip module_configs[transit][enabled]
13//! Panel.ControlChanged → Shell → update module_configs OR canvas visibility
14//! ```
15//!
16//! ## module_configs
17//!
18//! Mapa `module_id → JSON` con la configuración persistente de cada
19//! módulo (transit, progression, …). De ahí derivamos los
20//! `PipelineRequest` que la engine consume. Los toggles "visuales"
21//! del NatalModule (`show_sign_dial`, `show_houses`, …) NO viven acá
22//! — afectan solo el render del canvas, no la composición.
23
24use std::collections::HashMap;
25
26use gpui::{
27 Context, Entity, IntoElement, ParentElement, Render, SharedString, Styled, Window, div,
28 prelude::*, px,
29};
30
31use tahuantinsuyu_canvas::{
32 AstrologyCanvas, CanvasEvent, CanvasMode, ThumbnailItem, ThumbnailScope,
33};
34use tahuantinsuyu_engine::{
35 LayerKind, NatalOptions, OUTER_RING_MODULES, PipelineRequest, compose_with_options,
36};
37use tahuantinsuyu_model::{Chart, ChartId, ModuleState, TreeSelection};
38use tahuantinsuyu_panel::{ChartOption, ControlPanel, PanelEvent};
39use tahuantinsuyu_store::Store;
40use tahuantinsuyu_tree::{TahuantinsuyuTree, TreeEvent};
41use yahweh_bus::AppBus;
42use yahweh_theme::Theme;
43
44const TREE_WIDTH: f32 = 280.0;
45const PANEL_HEIGHT: f32 = 180.0;
46
47pub struct Shell {
48 store: Store,
49 #[allow(dead_code)]
50 bus: Entity<AppBus>,
51 tree: Entity<TahuantinsuyuTree>,
52 canvas: Entity<AstrologyCanvas>,
53 panel: Entity<ControlPanel>,
54 current_chart: Option<Chart>,
55 current_offset_minutes: i64,
56 /// Estado de los módulos overlay (transit, progression, …) por
57 /// `module_id`. Las claves dentro del JSON dependen del módulo (la
58 /// convención es `"enabled": bool` para el toggle principal).
59 module_configs: HashMap<String, serde_json::Value>,
60}
61
62impl Shell {
63 pub fn new(store: Store, cx: &mut Context<Self>) -> Self {
64 cx.observe_global::<Theme>(|_, cx| cx.notify()).detach();
65
66 let bus = cx.new(|_| AppBus);
67 let tree = cx.new(|cx| TahuantinsuyuTree::new(store.clone(), cx));
68 let canvas = cx.new(AstrologyCanvas::new);
69 let panel = cx.new(ControlPanel::new);
70
71 cx.subscribe(&tree, |this: &mut Self, _, ev: &TreeEvent, cx| {
72 this.on_tree_event(ev, cx);
73 })
74 .detach();
75
76 cx.subscribe(&panel, |this: &mut Self, _, ev: &PanelEvent, cx| {
77 this.on_panel_event(ev, cx);
78 })
79 .detach();
80
81 cx.subscribe(&canvas, |this: &mut Self, _, ev: &CanvasEvent, cx| {
82 this.on_canvas_event(ev, cx);
83 })
84 .detach();
85
86 let mut shell = Self {
87 store,
88 bus,
89 tree,
90 canvas,
91 panel,
92 current_chart: None,
93 current_offset_minutes: 0,
94 module_configs: HashMap::new(),
95 };
96 shell.refresh_chart_options(cx);
97 shell
98 }
99
100 /// Recarga la lista de opciones para los `Control::ChartPicker` y
101 /// la pushea al panel. Llamado al boot + tras cada
102 /// `TreeEvent::HierarchyChanged`.
103 fn refresh_chart_options(&self, cx: &mut Context<Self>) {
104 let charts = self.store.list_all_charts().unwrap_or_default();
105 let options: Vec<ChartOption> = charts
106 .into_iter()
107 .map(|c| ChartOption {
108 id: c.id.to_string(),
109 label: format!("{} — {}", c.label, format_birth_brief(&c.birth_data)),
110 })
111 .collect();
112 self.panel
113 .update(cx, |p, cx| p.set_chart_options(options, cx));
114 }
115
116 fn on_tree_event(&mut self, ev: &TreeEvent, cx: &mut Context<Self>) {
117 let selection = match ev {
118 TreeEvent::Selected(s) => s,
119 TreeEvent::Opened(s) => s,
120 TreeEvent::HierarchyChanged => {
121 // La jerarquía cambió (alta/baja de cartas) — refrescar
122 // las opciones del picker para que aparezcan / desaparezcan
123 // en el dropdown.
124 self.refresh_chart_options(cx);
125 cx.notify();
126 return;
127 }
128 };
129 self.apply_selection(selection.clone(), cx);
130 }
131
132 fn apply_selection(&mut self, sel: TreeSelection, cx: &mut Context<Self>) {
133 match sel {
134 TreeSelection::Chart(id) => {
135 let chart = match self.store.get_chart(id) {
136 Ok(c) => c,
137 Err(e) => {
138 eprintln!("[shell] get_chart {}: {}", id, e);
139 return;
140 }
141 };
142 let age = current_age_years(&chart.birth_data);
143 self.current_chart = Some(chart.clone());
144 self.current_offset_minutes = 0;
145 // 1) Defaults frescos para esta carta: edad objetivo =
146 // edad actual. Estos quedan en module_configs como
147 // valor base si el usuario nunca tocó el slider.
148 self.module_configs.clear();
149 for module_id in ["progression", "solar_arc", "planetary_return"] {
150 let entry = self
151 .module_configs
152 .entry(module_id.into())
153 .or_insert_with(|| serde_json::json!({}));
154 if let serde_json::Value::Object(map) = entry {
155 map.insert("target_age_years".into(), serde_json::json!(age));
156 }
157 }
158 // El módulo planetary_return además necesita un body
159 // por default — el shell elige "sun" si el usuario no
160 // tocó el Select. La persistencia luego puede pisar
161 // este valor.
162 if let Some(serde_json::Value::Object(map)) =
163 self.module_configs.get_mut("planetary_return")
164 {
165 map.entry(String::from("body"))
166 .or_insert(serde_json::json!("sun"));
167 }
168 // 2) Sobreescribir con lo que el usuario persistió la
169 // última vez para esta carta (SQLite `module_state`).
170 self.load_persisted_module_states(chart.id);
171 // 3) Sincronizar panel: active_kind + toggles/sliders.
172 self.panel.update(cx, |p, cx| {
173 p.set_active_kind(Some(chart.kind), cx);
174 });
175 self.sync_panel_from_configs(cx);
176 self.render_current(cx);
177 }
178 TreeSelection::Contact(id) => {
179 self.current_chart = None;
180 self.current_offset_minutes = 0;
181 let charts = self.store.list_charts(id).unwrap_or_default();
182 let items: Vec<ThumbnailItem> = charts
183 .into_iter()
184 .map(|c| ThumbnailItem {
185 chart_id: c.id,
186 label: SharedString::from(c.label),
187 subtitle: Some(SharedString::from(format!("{:?}", c.kind))),
188 preview: None,
189 })
190 .collect();
191 self.canvas.update(cx, |c, cx| {
192 c.set_mode(
193 CanvasMode::Thumbnails {
194 scope: ThumbnailScope::Contact(id),
195 items,
196 },
197 cx,
198 );
199 });
200 self.panel.update(cx, |p, cx| p.set_active_kind(None, cx));
201 }
202 TreeSelection::Group(id) => {
203 self.current_chart = None;
204 self.current_offset_minutes = 0;
205 let charts = self.store.charts_under_group(id).unwrap_or_default();
206 let items: Vec<ThumbnailItem> = charts
207 .into_iter()
208 .map(|c| ThumbnailItem {
209 chart_id: c.id,
210 label: SharedString::from(c.label),
211 subtitle: Some(SharedString::from(format!("{:?}", c.kind))),
212 preview: None,
213 })
214 .collect();
215 self.canvas.update(cx, |c, cx| {
216 c.set_mode(
217 CanvasMode::Thumbnails {
218 scope: ThumbnailScope::Group(id),
219 items,
220 },
221 cx,
222 );
223 });
224 self.panel.update(cx, |p, cx| p.set_active_kind(None, cx));
225 }
226 }
227 }
228
229 /// Deriva los `PipelineRequest` activos a partir del `module_configs`.
230 fn build_requests(&self) -> Vec<PipelineRequest> {
231 let mut requests = Vec::new();
232 if module_enabled(&self.module_configs, "transit") {
233 requests.push(PipelineRequest::Transit);
234 }
235 if module_enabled(&self.module_configs, "progression") {
236 let age = self.module_age_or_current("progression");
237 requests.push(PipelineRequest::SecondaryProgression {
238 target_age_years: age,
239 });
240 }
241 if module_enabled(&self.module_configs, "solar_arc") {
242 let age = self.module_age_or_current("solar_arc");
243 requests.push(PipelineRequest::SolarArc {
244 target_age_years: age,
245 });
246 }
247 if module_enabled(&self.module_configs, "synastry") {
248 if let Some(partner) = self.resolve_synastry_partner() {
249 requests.push(PipelineRequest::Synastry {
250 partner_chart: Box::new(partner),
251 });
252 }
253 }
254 if module_enabled(&self.module_configs, "planetary_return") {
255 let age = self.module_age_or_current("planetary_return");
256 let body = self
257 .module_configs
258 .get("planetary_return")
259 .and_then(|c| c.get("body"))
260 .and_then(|v| v.as_str())
261 .unwrap_or("sun")
262 .to_string();
263 requests.push(PipelineRequest::PlanetaryReturn {
264 body,
265 target_age_years: age,
266 });
267 }
268 requests
269 }
270
271 /// Resuelve la carta partner para sinastría: 1) si el picker tiene
272 /// un `partner_chart_id` válido en `module_configs`, lo usa; 2)
273 /// si no, cae al automático (primera carta hermana del contacto
274 /// actual). `None` si nada matchea — el request se salta.
275 fn resolve_synastry_partner(&self) -> Option<Chart> {
276 let manual = self
277 .module_configs
278 .get("synastry")
279 .and_then(|c| c.get("partner_chart_id"))
280 .and_then(|v| v.as_str())
281 .and_then(|s| s.parse::<tahuantinsuyu_model::ChartId>().ok())
282 .and_then(|id| self.store.get_chart(id).ok());
283 manual.or_else(|| self.find_synastry_partner_auto())
284 }
285
286 fn find_synastry_partner_auto(&self) -> Option<Chart> {
287 let current = self.current_chart.as_ref()?;
288 let siblings = self.store.list_charts(current.contact_id).ok()?;
289 siblings.into_iter().find(|c| c.id != current.id)
290 }
291
292 /// Deriva las `NatalOptions` activas a partir del `module_configs["natal"]`.
293 /// Si la entry no existe, devuelve defaults (majors=true, minors=false,
294 /// multiplier=1.0).
295 fn build_natal_options(&self) -> NatalOptions {
296 let cfg = self.module_configs.get("natal");
297 let read_bool = |key: &str, default: bool| -> bool {
298 cfg.and_then(|c| c.get(key))
299 .and_then(|v| v.as_bool())
300 .unwrap_or(default)
301 };
302 let read_f64 = |key: &str, default: f64| -> f64 {
303 cfg.and_then(|c| c.get(key))
304 .and_then(|v| v.as_f64())
305 .unwrap_or(default)
306 };
307 NatalOptions {
308 show_majors: read_bool("aspect_majors", true),
309 show_minors: read_bool("aspect_minors", false),
310 orb_multiplier: read_f64("orb_multiplier", 1.0),
311 }
312 }
313
314 /// Lee `module_state` desde SQLite para la carta dada y los mergea
315 /// con los defaults ya cargados en `module_configs`. Los valores
316 /// persistidos ganan sobre los defaults.
317 fn load_persisted_module_states(&mut self, chart_id: ChartId) {
318 let states = match self.store.list_module_states(chart_id) {
319 Ok(s) => s,
320 Err(e) => {
321 eprintln!("[shell] list_module_states {}: {}", chart_id, e);
322 return;
323 }
324 };
325 for st in states {
326 // Re-mergeamos `enabled` (columna separada en SQL) dentro
327 // del JSON config, así el resto del shell sigue leyendo
328 // todo desde una única estructura.
329 let mut combined = match st.config {
330 serde_json::Value::Object(m) => serde_json::Value::Object(m),
331 _ => serde_json::json!({}),
332 };
333 if let serde_json::Value::Object(map) = &mut combined {
334 map.insert("enabled".into(), serde_json::Value::Bool(st.enabled));
335 }
336 // Mergear sobre defaults previos (no sobreescribir si la
337 // entrada nueva no trae un campo).
338 match self.module_configs.entry(st.module_id) {
339 std::collections::hash_map::Entry::Vacant(v) => {
340 v.insert(combined);
341 }
342 std::collections::hash_map::Entry::Occupied(mut o) => {
343 if let (serde_json::Value::Object(dst), serde_json::Value::Object(src)) =
344 (o.get_mut(), &combined)
345 {
346 for (k, v) in src {
347 dst.insert(k.clone(), v.clone());
348 }
349 } else {
350 o.insert(combined);
351 }
352 }
353 }
354 }
355 }
356
357 /// Pushea cada toggle/slider/picker del `module_configs` al panel
358 /// para que la UI refleje el estado persistido al cargar una carta.
359 fn sync_panel_from_configs(&mut self, cx: &mut Context<Self>) {
360 let snapshot: Vec<(String, serde_json::Value)> = self
361 .module_configs
362 .iter()
363 .map(|(k, v)| (k.clone(), v.clone()))
364 .collect();
365 self.panel.update(cx, |p, cx| {
366 for (module_id, config) in &snapshot {
367 if let serde_json::Value::Object(map) = config {
368 for (key, value) in map {
369 if let Some(b) = value.as_bool() {
370 p.set_toggle(module_id, key, b, cx);
371 } else if let Some(f) = value.as_f64() {
372 p.set_slider(module_id, key, f, cx);
373 } else if let Some(s) = value.as_str() {
374 p.set_string(module_id, key, Some(s.to_string()), cx);
375 } else if value.is_null() {
376 p.set_string(module_id, key, None, cx);
377 }
378 }
379 }
380 }
381 });
382 }
383
384 /// Persiste el estado actual de un módulo a SQLite. Extrae
385 /// `enabled` del JSON y lo guarda en la columna dedicada; el resto
386 /// va al `config_json`.
387 fn persist_module(&self, module_id: &str) {
388 let Some(chart) = self.current_chart.as_ref() else {
389 return;
390 };
391 let Some(config) = self.module_configs.get(module_id) else {
392 return;
393 };
394 let enabled = config
395 .get("enabled")
396 .and_then(|v| v.as_bool())
397 .unwrap_or(false);
398 let mut clean = config.clone();
399 if let serde_json::Value::Object(map) = &mut clean {
400 map.remove("enabled");
401 }
402 let state = ModuleState {
403 chart_id: chart.id,
404 module_id: module_id.to_string(),
405 enabled,
406 config: clean,
407 };
408 if let Err(e) = self.store.upsert_module_state(&state) {
409 eprintln!("[shell] upsert_module_state {}: {}", module_id, e);
410 }
411 }
412
413 /// Lee `target_age_years` del módulo o cae a la edad actual del
414 /// sujeto (calculada desde la fecha de nacimiento y el reloj).
415 fn module_age_or_current(&self, module_id: &str) -> f64 {
416 self.module_configs
417 .get(module_id)
418 .and_then(|c| c.get("target_age_years"))
419 .and_then(|v| v.as_f64())
420 .unwrap_or_else(|| {
421 self.current_chart
422 .as_ref()
423 .map(|c| current_age_years(&c.birth_data))
424 .unwrap_or(0.0)
425 })
426 }
427
428 fn render_current(&mut self, cx: &mut Context<Self>) {
429 let Some(chart) = self.current_chart.as_ref() else {
430 return;
431 };
432 let requests = self.build_requests();
433 let natal_options = self.build_natal_options();
434 let render = match compose_with_options(
435 chart,
436 self.current_offset_minutes,
437 &requests,
438 &natal_options,
439 ) {
440 Ok(r) => r,
441 Err(e) => {
442 eprintln!(
443 "[shell] compose {} (+{}min, {} reqs): {}",
444 chart.id,
445 self.current_offset_minutes,
446 requests.len(),
447 e
448 );
449 return;
450 }
451 };
452 self.canvas.update(cx, |c, cx| {
453 c.set_mode(
454 CanvasMode::Wheel {
455 render: Box::new(render),
456 },
457 cx,
458 );
459 });
460 }
461
462 fn on_canvas_event(&mut self, ev: &CanvasEvent, cx: &mut Context<Self>) {
463 match ev {
464 CanvasEvent::TimeOffsetChanged(off) => {
465 self.current_offset_minutes = *off;
466 if self.current_chart.is_some() {
467 self.render_current(cx);
468 }
469 }
470 CanvasEvent::LayerVisibilityChanged { kind, visible } => {
471 // El toggle de Outer ([T]) no es visibility puro: dispara
472 // un pipeline distinto. Lo traducimos a un cambio en
473 // module_configs["transit"]["enabled"] + re-render.
474 if matches!(kind, LayerKind::Outer) {
475 set_module_enabled(&mut self.module_configs, "transit", *visible);
476 self.persist_module("transit");
477 self.panel.update(cx, |p, cx| {
478 p.set_toggle("transit", "enabled", *visible, cx)
479 });
480 self.render_current(cx);
481 return;
482 }
483 // El resto son visibility puros sobre el canvas. Sync el
484 // panel para que el toggle visual coincida con la hotkey.
485 let key = match kind {
486 LayerKind::SignDial => "show_sign_dial",
487 LayerKind::Houses => "show_houses",
488 LayerKind::Aspects => "show_aspects",
489 LayerKind::Bodies => "show_bodies",
490 _ => return,
491 };
492 self.panel
493 .update(cx, |p, cx| p.set_toggle("natal", key, *visible, cx));
494 }
495 CanvasEvent::ChartRequested(_) => {
496 // Fase 7: doble click sobre un thumbnail abre la carta.
497 }
498 }
499 }
500
501 fn on_panel_event(&mut self, ev: &PanelEvent, cx: &mut Context<Self>) {
502 match ev {
503 PanelEvent::ControlChanged {
504 module_id, key, value,
505 } => {
506 let bool_val = value.as_bool().unwrap_or(true);
507 if module_id == "natal" {
508 // Distinguimos: show_* = visibility (no recompose),
509 // aspect_*/orb_* = filtros de engine (recompose +
510 // persist).
511 let kind = match key.as_str() {
512 "show_sign_dial" => Some(LayerKind::SignDial),
513 "show_houses" => Some(LayerKind::Houses),
514 "show_aspects" => Some(LayerKind::Aspects),
515 "show_bodies" => Some(LayerKind::Bodies),
516 _ => None,
517 };
518 if let Some(k) = kind {
519 self.canvas
520 .update(cx, |c, cx| c.set_layer_visible(k, bool_val, cx));
521 } else {
522 // Filtros: actualizar module_configs + recompose.
523 let entry = self
524 .module_configs
525 .entry("natal".into())
526 .or_insert_with(|| serde_json::json!({}));
527 if let serde_json::Value::Object(map) = entry {
528 map.insert(key.clone(), value.clone());
529 }
530 self.persist_module("natal");
531 self.render_current(cx);
532 }
533 } else {
534 // Cualquier otro módulo: actualizamos su config y
535 // recompomemos. La engine vuelve a llamarse con el
536 // PipelineRequest derivado del nuevo estado.
537 let entry = self
538 .module_configs
539 .entry(module_id.clone())
540 .or_insert_with(|| serde_json::json!({}));
541 if let serde_json::Value::Object(map) = entry {
542 map.insert(key.clone(), value.clone());
543 }
544 // Transit, Synastry y Solar Return comparten el
545 // outer ring del canvas — son mutuamente excluyentes.
546 // Al prender uno, apagamos los otros + sync panel +
547 // persist.
548 if key == "enabled" && bool_val && OUTER_RING_MODULES.contains(&module_id.as_str()) {
549 for &other in OUTER_RING_MODULES.iter() {
550 if other != module_id && module_enabled(&self.module_configs, other) {
551 set_module_enabled(&mut self.module_configs, other, false);
552 let other_str = other.to_string();
553 self.panel.update(cx, |p, cx| {
554 p.set_toggle(&other_str, "enabled", false, cx)
555 });
556 self.persist_module(&other_str);
557 }
558 }
559 }
560 // Sincronizar visualmente el toggle [T] del canvas
561 // cuando el cambio afecta el outer ring (transit,
562 // synastry o solar_return).
563 if OUTER_RING_MODULES.contains(&module_id.as_str()) && key == "enabled" {
564 self.canvas.update(cx, |c, cx| {
565 c.set_layer_visible(LayerKind::Outer, bool_val, cx)
566 });
567 }
568 self.persist_module(module_id);
569 self.render_current(cx);
570 }
571 }
572 PanelEvent::ModuleToggled { .. } => {
573 // Fase 7: encender/apagar módulos enteros desde un
574 // header con switch (vs. el toggle por-control de hoy).
575 }
576 }
577 let _ = (&self.store, &self.tree, &self.bus);
578 }
579}
580
581// =====================================================================
582// Helpers de module_configs
583// =====================================================================
584
585// OUTER_RING_MODULES viene de tahuantinsuyu_engine — single source of
586// truth. Shell y canvas leen del mismo slice.
587
588
589/// Etiqueta breve para mostrar al elegir una carta en el picker:
590/// `"YYYY-MM-DD · Lugar"` cuando hay lugar, sino solo la fecha.
591fn format_birth_brief(birth: &tahuantinsuyu_model::StoredBirthData) -> String {
592 let date = format!("{:04}-{:02}-{:02}", birth.year, birth.month, birth.day);
593 match &birth.birthplace_label {
594 Some(p) if !p.is_empty() => format!("{} · {}", date, p),
595 _ => date,
596 }
597}
598
599/// Edad en años decimales desde el nacimiento hasta el reloj actual.
600/// Aproximación: ignora la TZ de nacimiento (no afecta a resolución de
601/// año) y usa una fracción de año tropical sobre los segundos Unix.
602fn current_age_years(birth: &tahuantinsuyu_model::StoredBirthData) -> f64 {
603 use std::time::{SystemTime, UNIX_EPOCH};
604 let now_secs = SystemTime::now()
605 .duration_since(UNIX_EPOCH)
606 .map(|d| d.as_secs_f64())
607 .unwrap_or(0.0);
608 let birth_year_frac = birth.year as f64
609 + (birth.month.saturating_sub(1) as f64) / 12.0
610 + (birth.day.saturating_sub(1) as f64) / 365.25;
611 let now_year_frac = 1970.0 + now_secs / (365.2422 * 86400.0);
612 (now_year_frac - birth_year_frac).max(0.0)
613}
614
615fn module_enabled(cfgs: &HashMap<String, serde_json::Value>, id: &str) -> bool {
616 cfgs.get(id)
617 .and_then(|c| c.get("enabled"))
618 .and_then(|v| v.as_bool())
619 .unwrap_or(false)
620}
621
622fn set_module_enabled(
623 cfgs: &mut HashMap<String, serde_json::Value>,
624 id: &str,
625 enabled: bool,
626) {
627 let entry = cfgs
628 .entry(id.to_string())
629 .or_insert_with(|| serde_json::json!({}));
630 if let serde_json::Value::Object(map) = entry {
631 map.insert("enabled".into(), serde_json::Value::Bool(enabled));
632 }
633}
634
635impl Render for Shell {
636 fn render(&mut self, _w: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
637 let theme = Theme::global(cx).clone();
638
639 let header = div()
640 .h(px(34.0))
641 .px(px(12.0))
642 .flex()
643 .flex_row()
644 .items_center()
645 .gap(px(10.0))
646 .border_b_1()
647 .border_color(theme.border)
648 .child(
649 div()
650 .text_size(px(13.0))
651 .text_color(theme.fg_text)
652 .child("☉ Tahuantinsuyu"),
653 )
654 .child(
655 div()
656 .text_size(px(10.0))
657 .text_color(theme.fg_muted)
658 .child("estudio de astrología profesional"),
659 );
660
661 let tree_panel = div()
662 .w(px(TREE_WIDTH))
663 .min_w(px(TREE_WIDTH))
664 .h_full()
665 .border_r_1()
666 .border_color(theme.border)
667 .child(self.tree.clone());
668
669 let canvas_panel = div().flex_grow().h_full().child(self.canvas.clone());
670
671 let main_row = div()
672 .flex_grow()
673 .flex()
674 .flex_row()
675 .child(tree_panel)
676 .child(canvas_panel);
677
678 let bottom_panel = div()
679 .h(px(PANEL_HEIGHT))
680 .min_h(px(PANEL_HEIGHT))
681 .w_full()
682 .border_t_1()
683 .border_color(theme.border)
684 .child(self.panel.clone());
685
686 div()
687 .size_full()
688 .bg(theme.bg_app.clone())
689 .flex()
690 .flex_col()
691 .child(header)
692 .child(main_row)
693 .child(bottom_panel)
694 }
695}