Files
brahman/crates/modules/tahuantinsuyu/tahuantinsuyu-modules/src/lib.rs
T
sergio 360797132e feat(tahuantinsuyu): fase 4 — jog-dial perimetral, hotkeys y panel interactivo
Time scrubbing por drag en el aro exterior del wheel: rota visualmente
mientras dura el drag, al soltar traduce el delta angular a minutos
(1° = 4 min sideral, CW = forward) y emite CanvasEvent::TimeOffsetChanged.
La Shell recomputa con engine::compute_at_offset y el ascendant rotado
queda en la nueva posición. Snap visual a 0° tras commit.

- engine: nueva variante compute_at_offset(chart, minutes) que suma
  segundos al UTC base via add_seconds + Instant::from_utc y corre la
  pipeline normal. compute() es ahora wrapper con offset=0.
- canvas: estado nuevo layer_visibility + drag_jog. Mouse handlers
  registrados desde el paint callback (mismo patrón que splitter/tiled).
  Hotkeys D/H/X/P toggle SignDial/Houses/Aspects/Bodies, R resetea
  offset. FocusHandle + click-to-focus para recibir teclas. Indicador
  ⏱ ±Xd HH:MM en el footer con color highlight cuando el offset != 0.
  paint_wheel + glyph overlays respetan layer_visibility (skip capas
  ocultas).
- modules: NatalModule.controls() ahora expone show_sign_dial /
  show_houses / show_aspects / show_bodies con hotkeys [D/H/X/P], más
  el slider de armónico.
- panel: ControlPanel mantiene toggle_state cache (module_id, key) →
  bool, inicializa desde defaults al cambiar de ChartKind. Click
  invierte el toggle visualmente y emite ControlChanged. Nuevo
  set_toggle(module, key, value) para que la Shell mantenga sync
  cuando el canvas se autotogglea por hotkey.
- shell: nuevo current_chart + current_offset_minutes. render_current()
  delega a compute_at_offset. Suscripción a CanvasEvent traduce
  TimeOffsetChanged → re-render, LayerVisibilityChanged → panel sync.
  Suscripción a PanelEvent::ControlChanged traduce show_* keys a
  set_layer_visible sobre el canvas.

Todos los tests verdes. La fase 5 sumará módulos extra (transit,
progression, synastry, uranian) + extracción de eternal de lo que falte.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-17 10:15:09 +00:00

238 lines
7.2 KiB
Rust

//! `tahuantinsuyu-modules` — registry de módulos astrológicos.
//!
//! Cada tipo de astrología (natal, tránsito, progresión, sinastría,
//! Uraniano, …) es un **módulo** que declara:
//!
//! - Qué `Layer`s aporta al `RenderModel`.
//! - Qué `Control`s expone al panel inferior (toggles, sliders, selects).
//! - Hotkeys opcionales.
//! - Si su cómputo es lazy (sólo cuando se activa) o eager.
//!
//! El registry es un `Vec<&dyn Module>` estático: el canvas consulta
//! "para esta `ChartKind`, ¿qué módulos están disponibles?" y el panel
//! pinta sus controles. Activar / desactivar persiste en
//! `ModuleState` (en la store).
//!
//! Esta fase 1 trae el trait + un módulo `NatalModule` de placeholder.
//! En fases posteriores agregamos Transit, Progression, Synastry,
//! Composite, SolarArc, Uranian, FixedStars, Dignities, Lots…
#![forbid(unsafe_code)]
#![warn(rust_2018_idioms)]
use serde::{Deserialize, Serialize};
use tahuantinsuyu_engine::Layer;
use tahuantinsuyu_model::{Chart, ChartKind};
// =====================================================================
// Trait Module
// =====================================================================
/// Una capa de astrología enchufable.
///
/// `Send + Sync` para que el registry sea estático y se pueda consultar
/// desde cualquier thread (el cómputo pesado va a un background executor).
pub trait Module: Send + Sync {
/// Identidad estable del módulo. Coincide con `ModuleState.module_id`
/// en la store.
fn id(&self) -> &'static str;
/// Etiqueta amigable para el panel.
fn label(&self) -> &'static str;
/// Breve descripción para tooltip.
fn description(&self) -> &'static str;
/// Para qué tipos de carta tiene sentido este módulo. El panel filtra
/// con esto al armar la lista de toggles disponibles.
fn applies_to(&self, kind: ChartKind) -> bool;
/// Si el módulo está activado por default al crear una carta.
fn enabled_by_default(&self) -> bool {
false
}
/// Controles que aporta al panel inferior.
fn controls(&self) -> Vec<Control> {
Vec::new()
}
/// Computa las capas que este módulo aporta al RenderModel de
/// `chart`. La engine la llama solo si el módulo está activado
/// para esa carta.
///
/// Devuelve `Vec` (no Option) — un módulo puede no aportar capas
/// si su config interna lo apaga (ej. "Uranian: mostrar simetría
/// = false"); en ese caso retorna `Vec::new()`.
fn compute_layers(&self, chart: &Chart, config: &serde_json::Value) -> Vec<Layer>;
}
// =====================================================================
// Controls expuestos al panel
// =====================================================================
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum Control {
Toggle {
key: String,
label: String,
default: bool,
hotkey: Option<String>,
},
Slider {
key: String,
label: String,
min: f64,
max: f64,
step: f64,
default: f64,
},
Select {
key: String,
label: String,
options: Vec<SelectOption>,
default: String,
},
/// Texto libre — útil para etiquetas, comentarios.
TextInput {
key: String,
label: String,
default: String,
},
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SelectOption {
pub value: String,
pub label: String,
}
// =====================================================================
// Registry
// =====================================================================
/// Lista estática de módulos disponibles. La app los registra al boot.
pub struct Registry {
modules: Vec<Box<dyn Module>>,
}
impl Registry {
/// Registry con todos los módulos built-in. La app llama esto al
/// boot y luego usa `find()` / `for_kind()` para consultar.
pub fn with_builtins() -> Self {
let mut r = Self { modules: Vec::new() };
r.register(Box::new(natal::NatalModule));
r
}
pub fn register(&mut self, m: Box<dyn Module>) {
self.modules.push(m);
}
pub fn all(&self) -> &[Box<dyn Module>] {
&self.modules
}
pub fn find(&self, id: &str) -> Option<&dyn Module> {
self.modules
.iter()
.find(|m| m.id() == id)
.map(|m| m.as_ref())
}
pub fn for_kind(&self, kind: ChartKind) -> Vec<&dyn Module> {
self.modules
.iter()
.filter(|m| m.applies_to(kind))
.map(|m| m.as_ref())
.collect()
}
}
// =====================================================================
// NatalModule — placeholder fase 1
// =====================================================================
pub mod natal {
use super::*;
use tahuantinsuyu_engine::compute_mock;
pub struct NatalModule;
impl Module for NatalModule {
fn id(&self) -> &'static str {
"natal"
}
fn label(&self) -> &'static str {
"Carta natal"
}
fn description(&self) -> &'static str {
"Posiciones natales, casas y aspectos."
}
fn applies_to(&self, kind: ChartKind) -> bool {
matches!(kind, ChartKind::Natal)
}
fn enabled_by_default(&self) -> bool {
true
}
fn controls(&self) -> Vec<Control> {
vec![
Control::Toggle {
key: "show_sign_dial".into(),
label: "Dial zodiacal".into(),
default: true,
hotkey: Some("D".into()),
},
Control::Toggle {
key: "show_houses".into(),
label: "Casas".into(),
default: true,
hotkey: Some("H".into()),
},
Control::Toggle {
key: "show_aspects".into(),
label: "Aspectos".into(),
default: true,
hotkey: Some("X".into()),
},
Control::Toggle {
key: "show_bodies".into(),
label: "Cuerpos".into(),
default: true,
hotkey: Some("P".into()),
},
Control::Slider {
key: "harmonic".into(),
label: "Armónico".into(),
min: 1.0,
max: 20.0,
step: 1.0,
default: 1.0,
},
]
}
fn compute_layers(&self, chart: &Chart, _cfg: &serde_json::Value) -> Vec<Layer> {
// Fase 1: delega al mock de la engine para que la UI tenga
// algo que pintar. Fase 3 reemplaza con `engine::compute`
// contra `eternal-astrology`.
compute_mock(chart).layers
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn registry_finds_natal() {
let r = Registry::with_builtins();
assert!(r.find("natal").is_some());
assert_eq!(r.for_kind(ChartKind::Natal).len(), 1);
assert!(r.for_kind(ChartKind::Synastry).is_empty());
}
}