diff --git a/crates/apps/tahuantinsuyu/src/shell.rs b/crates/apps/tahuantinsuyu/src/shell.rs index 0cd033e..15604de 100644 --- a/crates/apps/tahuantinsuyu/src/shell.rs +++ b/crates/apps/tahuantinsuyu/src/shell.rs @@ -1300,6 +1300,83 @@ impl Shell { // Fase 7: encender/apagar módulos enteros desde un // header con switch (vs. el toggle por-control de hoy). } + PanelEvent::Action { module_id, key } => { + self.on_panel_action(module_id.clone(), key.clone(), cx); + } + } + } + + /// Click sobre un `Control::Action` del panel. Por ahora maneja: + /// - planetary_return.save_as_free → captura la carta del + /// retorno actual (cuerpo + edad) como FreeChart con sufijo + /// `rs-{N}` / `lunar-{N}` / etc. según el cuerpo elegido. + /// + /// Otros módulos overlay (progression, solar_arc, primary_directions) + /// son extensión natural — TODO. + fn on_panel_action(&mut self, module_id: String, key: String, cx: &mut Context) { + if module_id == "planetary_return" && key == "save_as_free" { + self.save_planetary_return_as_free(cx); + } + } + + /// Computa la carta del retorno planetario actual (con cuerpo + + /// edad del módulo) y la inserta como FreeChart. El usuario + /// puede después "Guardar como…" para persistirla bajo un + /// contacto (típicamente el mismo del natal). + fn save_planetary_return_as_free(&mut self, cx: &mut Context) { + let Some(natal) = self.current_chart.as_ref() else { + eprintln!("[shell] save_planetary_return: sin carta activa"); + return; + }; + if natal.id == ChartId::default() { + eprintln!("[shell] save_planetary_return: la carta activa es libre, no natal"); + return; + } + let cfg = self + .module_configs + .get("planetary_return") + .cloned() + .unwrap_or(serde_json::json!({})); + let body = cfg + .get("body") + .and_then(|v| v.as_str()) + .unwrap_or("sun") + .to_string(); + let age = self.module_age_or_current("planetary_return"); + let shift_days = cfg + .get("shift_days") + .and_then(|v| v.as_f64()) + .unwrap_or(0.0) as i64; + + // Pedimos al engine la fecha exacta del retorno. La engine + // expone `compute_planetary_return_chart` que devuelve un + // `StoredBirthData` listo para reusar como carta natal. + match tahuantinsuyu_engine::compute_planetary_return_chart( + natal, &body, age, shift_days, + ) { + Ok((birth, instant_label)) => { + let suffix = match body.as_str() { + "sun" => "rs", + "moon" => "lunar", + other => other, + }; + let label = format!( + "{} {}-{:.0}a · {}", + natal.label, suffix, age, instant_label + ); + let id = FreeChartId(format!("free-{}", self.next_free_id)); + self.next_free_id += 1; + let mut chart = natal.clone(); + chart.id = ChartId::default(); + chart.label = label; + chart.birth_data = birth; + self.free_charts.insert(id.clone(), chart); + self.push_free_charts_to_tree(cx); + self.apply_selection(TreeSelection::FreeChart(id), cx); + } + Err(e) => { + eprintln!("[shell] compute_planetary_return_chart: {}", e); + } } } } diff --git a/crates/modules/tahuantinsuyu/tahuantinsuyu-engine/src/bridge.rs b/crates/modules/tahuantinsuyu/tahuantinsuyu-engine/src/bridge.rs index d77acaf..8fbe0bc 100644 --- a/crates/modules/tahuantinsuyu/tahuantinsuyu-engine/src/bridge.rs +++ b/crates/modules/tahuantinsuyu/tahuantinsuyu-engine/src/bridge.rs @@ -1017,6 +1017,100 @@ fn build_synastry_overlay( /// Sun = retorno solar anual, Moon = mensual, Júpiter/Saturno = /// generacionales. Esa nueva carta va en el anillo externo (compartido /// con Transit/Synastry, mutuamente excluyentes a nivel de Shell). +/// Computa la carta del retorno planetario actual y devuelve los +/// datos necesarios para construir un `Chart` standalone que el +/// caller puede mostrar/persistir. +/// +/// Devuelve `(StoredBirthData, instant_label)`: +/// - `StoredBirthData` con birth_data del retorno (year/month/day/... +/// del instante del retorno, mismas coordenadas que el natal). +/// - `instant_label` formato corto del momento (ej. "2024-03-14 +/// 05:22 UTC") — el shell lo concatena en el label final. +pub fn compute_planetary_return_chart( + chart: &Chart, + body_str: &str, + target_age_years: f64, + shift_days: i64, +) -> Result<(tahuantinsuyu_model::StoredBirthData, String), EngineError> { + let (birth_e, config_e, _observer) = build_eternal_inputs(chart, 0)?; + let session = session()?; + let natal = NatalChart::compute(&birth_e, &config_e, session) + .map_err(|e| EngineError::Eternal(format!("NatalChart::compute: {:?}", e)))?; + let body = map_body(body_str) + .ok_or_else(|| EngineError::Eternal(format!("body desconocido: {}", body_str)))?; + let natal_p = natal.placement(body).ok_or_else(|| { + EngineError::Eternal(format!( + "natal chart sin {} — return imposible", + body.name() + )) + })?; + let natal_lon = natal_p.longitude.longitude_rad(); + + let after_seconds = + (target_age_years * 365.242190 - 30.0 + shift_days as f64) * 86400.0; + const TWO_TROPICAL: f64 = 365.242190 * 86400.0 * 2.0; + let after_utc = natal + .birth + .instant + .utc() + .add_seconds(after_seconds.max(-TWO_TROPICAL)); + let after = ESInstant::from_utc(after_utc); + + let return_instant = next_return(session, body, natal_lon, after, None) + .map_err(|e| EngineError::Eternal(format!("next_return {}: {:?}", body.name(), e)))?; + + // Extraer year/month/day/hour/min/sec del momento del retorno. + // `to_iso8601` devuelve "YYYY-MM-DDTHH:MM:SS.sss" — parseamos los + // 5 campos relevantes. La precisión está en sub-segundo; usamos + // segundo entero (StoredBirthData::second es f64 pero el campo + // se persiste así). + let iso = return_instant.utc().to_iso8601(); + let (year, month, day, hour, minute, second) = parse_iso8601_components(&iso) + .ok_or_else(|| EngineError::Eternal(format!("iso8601 inválido: {}", iso)))?; + + let stored = tahuantinsuyu_model::StoredBirthData { + year, + month, + day, + hour, + minute, + second, + // El return se computa en la TZ del observador natal (es la + // convención clásica del Solar return). Heredamos también + // lat/lon/alt. + tz_offset_minutes: chart.birth_data.tz_offset_minutes, + latitude_deg: chart.birth_data.latitude_deg, + longitude_deg: chart.birth_data.longitude_deg, + altitude_m: chart.birth_data.altitude_m, + time_certainty: Default::default(), + subject_name: chart.birth_data.subject_name.clone(), + birthplace_label: chart.birth_data.birthplace_label.clone(), + }; + let label = format!( + "{:04}-{:02}-{:02} {:02}:{:02} UTC", + year, month, day, hour, minute + ); + Ok((stored, label)) +} + +/// Parsea "YYYY-MM-DDTHH:MM:SS[.fff]" a `(year, month, day, hour, +/// minute, second_float)`. Retorna `None` si el formato no encaja. +fn parse_iso8601_components(s: &str) -> Option<(i32, u32, u32, u32, u32, f64)> { + // Split en T y luego campo por campo. + let mut parts = s.splitn(2, 'T'); + let date = parts.next()?; + let time = parts.next()?; + let mut d = date.split('-'); + let year: i32 = d.next()?.parse().ok()?; + let month: u32 = d.next()?.parse().ok()?; + let day: u32 = d.next()?.parse().ok()?; + let mut t = time.split(':'); + let hour: u32 = t.next()?.parse().ok()?; + let minute: u32 = t.next()?.parse().ok()?; + let second: f64 = t.next()?.parse().ok()?; + Some((year, month, day, hour, minute, second)) +} + /// Cross aspects natal × return. fn build_planetary_return_overlay( natal: &NatalChart, diff --git a/crates/modules/tahuantinsuyu/tahuantinsuyu-engine/src/lib.rs b/crates/modules/tahuantinsuyu/tahuantinsuyu-engine/src/lib.rs index 309a451..015c0c6 100644 --- a/crates/modules/tahuantinsuyu/tahuantinsuyu-engine/src/lib.rs +++ b/crates/modules/tahuantinsuyu/tahuantinsuyu-engine/src/lib.rs @@ -422,6 +422,21 @@ pub fn compute_with_transits_at_now( compose(chart, offset_minutes, &[PipelineRequest::Transit]) } +/// Computa la carta del retorno planetario actual (cuerpo + edad) +/// como `StoredBirthData` standalone — la app la usa para crear +/// una `FreeChart` que el usuario puede después persistir en un +/// contacto. Devuelve también un label-corto del instante para +/// concatenar al nombre. +#[cfg(feature = "eternal-bridge")] +pub fn compute_planetary_return_chart( + chart: &Chart, + body: &str, + target_age_years: f64, + shift_days: i64, +) -> Result<(tahuantinsuyu_model::StoredBirthData, String), EngineError> { + bridge::compute_planetary_return_chart(chart, body, target_age_years, shift_days) +} + /// Helper retrocompatible: construye un `PlanetaryReturn` con /// `shift_days = 0`. Útil para llamadores que no necesitan ajuste /// fino (todos los Solar return y muchos casos básicos). diff --git a/crates/modules/tahuantinsuyu/tahuantinsuyu-modules/src/lib.rs b/crates/modules/tahuantinsuyu/tahuantinsuyu-modules/src/lib.rs index 27ce5a6..229cc11 100644 --- a/crates/modules/tahuantinsuyu/tahuantinsuyu-modules/src/lib.rs +++ b/crates/modules/tahuantinsuyu/tahuantinsuyu-modules/src/lib.rs @@ -109,6 +109,14 @@ pub enum Control { key: String, label: String, }, + /// Botón sin estado — el click dispara un `PanelEvent::Action` + /// con `key`. El panel lo pinta como pill clickeable. Útil para + /// "Guardar como carta libre" en los módulos overlay con + /// transformación (RS, progresión, solar arc, GR). + Action { + key: String, + label: String, + }, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -507,6 +515,14 @@ pub mod planetary_return { step: 1.0, default: 0.0, }, + // Botón: captura la carta del retorno actual (cuerpo + + // edad) como FreeChart con label `{contacto} rs-{N}` + // (o `lunar-{N}` etc. según el cuerpo). El usuario + // luego decide si guardarla en un contacto. + Control::Action { + key: "save_as_free".into(), + label: "💾 Guardar retorno como carta libre".into(), + }, ] } fn compute_layers(&self, _chart: &Chart, _cfg: &serde_json::Value) -> Vec { diff --git a/crates/modules/tahuantinsuyu/tahuantinsuyu-panel/src/lib.rs b/crates/modules/tahuantinsuyu/tahuantinsuyu-panel/src/lib.rs index d2a127f..6ca8af1 100644 --- a/crates/modules/tahuantinsuyu/tahuantinsuyu-panel/src/lib.rs +++ b/crates/modules/tahuantinsuyu/tahuantinsuyu-panel/src/lib.rs @@ -45,6 +45,13 @@ pub enum PanelEvent { key: String, value: serde_json::Value, }, + /// Click sobre un `Control::Action`. El shell decide qué hacer + /// (típicamente: capturar la carta derivada del overlay como + /// `FreeChart`). + Action { + module_id: String, + key: String, + }, } /// Opción que el host inyecta al panel para que los `Control::ChartPicker` @@ -544,9 +551,51 @@ impl ControlPanel { default, } => self.render_select(theme, module_id, key, label, options, default, cx), Control::TextInput { label, default, .. } => display_row(theme, label, default), + Control::Action { key, label } => { + self.render_action(theme, module_id, key, label, cx) + } } } + fn render_action( + &self, + theme: &Theme, + module_id: &str, + key: &str, + label: &str, + cx: &mut Context<'_, Self>, + ) -> gpui::Div { + let id_str: SharedString = + SharedString::from(format!("tts-action-{}-{}", module_id, key)); + let id_for_listener = (module_id.to_string(), key.to_string()); + let btn = div() + .id(gpui::ElementId::from(id_str)) + .flex() + .flex_row() + .items_center() + .justify_center() + .px(px(10.0)) + .py(px(5.0)) + .rounded(px(6.0)) + .bg(theme.bg_button()) + .hover(|s| s.bg(theme.bg_button_hover())) + .border_1() + .border_color(theme.border) + .text_size(px(11.0)) + .text_color(theme.fg_text) + .child(SharedString::from(label.to_string())) + .on_click(cx.listener(move |_this, _: &ClickEvent, _, cx| { + let (m, k) = id_for_listener.clone(); + cx.emit(PanelEvent::Action { + module_id: m, + key: k, + }); + })); + // Wrap en Div plano para que el tipo coincida con el resto + // de los renderers (`render_control` espera `gpui::Div`). + div().child(btn) + } + fn render_toggle( &self, theme: &Theme,