refactor(tahuantinsuyu): fase 6 — Modules pluggables vía compose + PipelineRequest

El shell ya no carga el flag `show_transits: bool` ni hardcodea qué
pipeline corre. La engine expone una sola API `compose(chart, offset,
&[PipelineRequest])` que la shell alimenta a partir de un map
`module_configs: HashMap<String, serde_json::Value>`. Los toggles de
overlay (transit hoy, progression/synastry/solar_arc en fase 7) viven
como módulos propios en el panel.

- engine: PipelineRequest enum (variante Transit por ahora; comentarios
  con el roadmap de SecondaryProgression/SolarArc/Synastry). compose()
  es la nueva entrada canónica; compute / compute_at_offset /
  compute_with_transits_at_now quedan como atajos retrocompatibles que
  delegan en compose. bridge.rs refactor: extraído build_transit_overlay
  como helper que muta &mut RenderModel, listo para que más pipelines
  apilen capas encima.
- modules: nuevo módulo `transit::TransitModule` (id "transit", toggle
  "enabled" con hotkey [T], applies_to Natal). Sacado el toggle
  show_transits de NatalModule — ahora cada módulo declara lo suyo.
  Registry::with_builtins() registra ambos. Test asegura los dos
  aplican a Natal.
- panel: sin cambios — ya itera Registry::for_kind(kind) y renderea
  cada módulo aplicable con sus controls. La adición del TransitModule
  aparece automática como segunda card en el panel.
- shell: replace show_transits por module_configs map. build_requests()
  deriva PipelineRequest::Transit cuando module_configs["transit"]
  ["enabled"] == true. on_panel_event: toggles del NatalModule afectan
  solo visibility del canvas; toggles de otros módulos van al
  module_configs y disparan render_current. on_canvas_event: [T]
  hotkey → flip transit.enabled + sync panel + recompose. apps Cargo
  agrega serde_json como dep directa.

Todos los tests verdes. Fase 7 puede sumar overlays adicionales
(progression, solar_arc) solo agregando variantes a PipelineRequest +
helpers en bridge + módulos declarativos — sin tocar el shell.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
sergio
2026-05-17 10:31:11 +00:00
parent 4d14a4495f
commit d4761bf238
6 changed files with 215 additions and 117 deletions
+1
View File
@@ -20,6 +20,7 @@ yahweh-bus = { workspace = true }
yahweh-theme = { workspace = true }
gpui = { workspace = true }
directories = { workspace = true }
serde_json = { workspace = true }
[[bin]]
name = "tahuantinsuyu"
+90 -38
View File
@@ -6,13 +6,22 @@
//! Flujo:
//!
//! ```text
//! Tree.Selected(Chart) → Shell → load chart + compute + set_mode(Wheel)
//! Tree.Selected(Chart) → Shell → load chart + compose + set_mode(Wheel)
//! Tree.Selected(Group/Contact)→ Shell → charts_under_* + set_mode(Thumbnails)
//! Canvas.TimeOffsetChanged → Shell → compute_at_offset(current_chart, off)
//! → set_mode(Wheel) con la rueda re-pintada
//! Canvas.LayerVisibility... → Shell → Panel.set_toggle (mantener sync visual)
//! Panel.ControlChanged → Shell → Canvas.set_layer_visible (show_*)
//! Canvas.TimeOffsetChanged → Shell → compose(current_chart, off, requests)
//! Canvas.LayerVisibility[T] Shell flip module_configs[transit][enabled]
//! Panel.ControlChanged → Shell → update module_configs OR canvas visibility
//! ```
//!
//! ## module_configs
//!
//! Mapa `module_id → JSON` con la configuración persistente de cada
//! módulo (transit, progression, …). De ahí derivamos los
//! `PipelineRequest` que la engine consume. Los toggles "visuales"
//! del NatalModule (`show_sign_dial`, `show_houses`, …) NO viven acá
//! — afectan solo el render del canvas, no la composición.
use std::collections::HashMap;
use gpui::{
Context, Entity, IntoElement, ParentElement, Render, SharedString, Styled, Window, div,
@@ -22,7 +31,7 @@ use gpui::{
use tahuantinsuyu_canvas::{
AstrologyCanvas, CanvasEvent, CanvasMode, ThumbnailItem, ThumbnailScope,
};
use tahuantinsuyu_engine::{LayerKind, compute_at_offset, compute_with_transits_at_now};
use tahuantinsuyu_engine::{LayerKind, PipelineRequest, compose};
use tahuantinsuyu_model::{Chart, TreeSelection};
use tahuantinsuyu_panel::{ControlPanel, PanelEvent};
use tahuantinsuyu_store::Store;
@@ -40,13 +49,12 @@ pub struct Shell {
tree: Entity<TahuantinsuyuTree>,
canvas: Entity<AstrologyCanvas>,
panel: Entity<ControlPanel>,
/// Carta abierta actualmente en el canvas. La cacheamos para poder
/// recomputarla con time-offsets sin re-leer la DB cada vez.
current_chart: Option<Chart>,
current_offset_minutes: i64,
/// Overlay de tránsitos al instante actual sobre la natal. Disparado
/// por el toggle `show_transits` del panel o la hotkey `[T]`.
show_transits: bool,
/// Estado de los módulos overlay (transit, progression, …) por
/// `module_id`. Las claves dentro del JSON dependen del módulo (la
/// convención es `"enabled": bool` para el toggle principal).
module_configs: HashMap<String, serde_json::Value>,
}
impl Shell {
@@ -81,7 +89,7 @@ impl Shell {
panel,
current_chart: None,
current_offset_minutes: 0,
show_transits: false,
module_configs: HashMap::new(),
}
}
@@ -164,23 +172,28 @@ impl Shell {
}
}
/// Deriva los `PipelineRequest` activos a partir del `module_configs`.
fn build_requests(&self) -> Vec<PipelineRequest> {
let mut requests = Vec::new();
if module_enabled(&self.module_configs, "transit") {
requests.push(PipelineRequest::Transit);
}
requests
}
fn render_current(&mut self, cx: &mut Context<Self>) {
let Some(chart) = self.current_chart.as_ref() else {
return;
};
let result = if self.show_transits {
compute_with_transits_at_now(chart, self.current_offset_minutes)
} else {
compute_at_offset(chart, self.current_offset_minutes)
};
let render = match result {
let requests = self.build_requests();
let render = match compose(chart, self.current_offset_minutes, &requests) {
Ok(r) => r,
Err(e) => {
eprintln!(
"[shell] compute {}{} (+{}min): {}",
"[shell] compose {} (+{}min, {} reqs): {}",
chart.id,
if self.show_transits { " +transits" } else { "" },
self.current_offset_minutes,
requests.len(),
e
);
return;
@@ -205,17 +218,19 @@ impl Shell {
}
}
CanvasEvent::LayerVisibilityChanged { kind, visible } => {
// El toggle de Outer (hotkey [T]) significa "transit
// overlay" — no es solo un layer hide, dispara un
// recompute distinto. El resto son visibility puros.
// El toggle de Outer ([T]) no es visibility puro: dispara
// un pipeline distinto. Lo traducimos a un cambio en
// module_configs["transit"]["enabled"] + re-render.
if matches!(kind, LayerKind::Outer) {
self.show_transits = *visible;
set_module_enabled(&mut self.module_configs, "transit", *visible);
self.panel.update(cx, |p, cx| {
p.set_toggle("natal", "show_transits", *visible, cx)
p.set_toggle("transit", "enabled", *visible, cx)
});
self.render_current(cx);
return;
}
// El resto son visibility puros sobre el canvas. Sync el
// panel para que el toggle visual coincida con la hotkey.
let key = match kind {
LayerKind::SignDial => "show_sign_dial",
LayerKind::Houses => "show_houses",
@@ -227,7 +242,7 @@ impl Shell {
.update(cx, |p, cx| p.set_toggle("natal", key, *visible, cx));
}
CanvasEvent::ChartRequested(_) => {
// Fase 5: doble click sobre un thumbnail abre la carta.
// Fase 7: doble click sobre un thumbnail abre la carta.
}
}
}
@@ -237,16 +252,10 @@ impl Shell {
PanelEvent::ControlChanged {
module_id, key, value,
} => {
let visible = value.as_bool().unwrap_or(true);
let bool_val = value.as_bool().unwrap_or(true);
if module_id == "natal" {
if key == "show_transits" {
self.show_transits = visible;
self.canvas.update(cx, |c, cx| {
c.set_layer_visible(LayerKind::Outer, visible, cx)
});
self.render_current(cx);
return;
}
// Toggles puramente visuales — solo afectan visibility
// del render actual, sin recomponer.
let kind = match key.as_str() {
"show_sign_dial" => Some(LayerKind::SignDial),
"show_houses" => Some(LayerKind::Houses),
@@ -256,19 +265,62 @@ impl Shell {
};
if let Some(k) = kind {
self.canvas
.update(cx, |c, cx| c.set_layer_visible(k, visible, cx));
.update(cx, |c, cx| c.set_layer_visible(k, bool_val, cx));
}
} else {
// Cualquier otro módulo: actualizamos su config y
// recompomemos. La engine vuelve a llamarse con el
// PipelineRequest derivado del nuevo estado.
let entry = self
.module_configs
.entry(module_id.clone())
.or_insert_with(|| serde_json::json!({}));
if let serde_json::Value::Object(map) = entry {
map.insert(key.clone(), value.clone());
}
// Sincronizar visualmente el toggle [T] del canvas
// cuando el cambio fue al "enabled" del transit.
if module_id == "transit" && key == "enabled" {
self.canvas.update(cx, |c, cx| {
c.set_layer_visible(LayerKind::Outer, bool_val, cx)
});
}
self.render_current(cx);
}
}
PanelEvent::ModuleToggled { .. } => {
// Fase 6: encender/apagar módulos enteros (Progression,
// Synastry, Uranian).
// Fase 7: encender/apagar módulos enteros desde un
// header con switch (vs. el toggle por-control de hoy).
}
}
let _ = (&self.store, &self.tree, &self.bus);
}
}
// =====================================================================
// Helpers de module_configs
// =====================================================================
fn module_enabled(cfgs: &HashMap<String, serde_json::Value>, id: &str) -> bool {
cfgs.get(id)
.and_then(|c| c.get("enabled"))
.and_then(|v| v.as_bool())
.unwrap_or(false)
}
fn set_module_enabled(
cfgs: &mut HashMap<String, serde_json::Value>,
id: &str,
enabled: bool,
) {
let entry = cfgs
.entry(id.to_string())
.or_insert_with(|| serde_json::json!({}));
if let serde_json::Value::Object(map) = entry {
map.insert("enabled".into(), serde_json::Value::Bool(enabled));
}
}
impl Render for Shell {
fn render(&mut self, _w: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let theme = Theme::global(cx).clone();