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>
This commit is contained in:
sergio
2026-05-17 10:15:09 +00:00
parent f4944218e2
commit 360797132e
6 changed files with 862 additions and 429 deletions
+101 -52
View File
@@ -3,22 +3,16 @@
//! Es el "director de orquesta": dueño del tree, del canvas y del panel,
//! reenvía eventos entre ellos y aplica las mutaciones en la store.
//!
//! Flujo típico:
//! Flujo:
//!
//! ```text
//! Tree.Selected(Chart) → Shell → Canvas.set_mode(Wheel)
//! → Panel.set_active_kind(chart.kind)
//!
//! Tree.Selected(Group) Shell → Canvas.set_mode(Thumbnails{…})
//! → Panel.set_active_kind(None)
//!
//! Panel.ModuleToggled → Shell → Store.upsert_module_state
//! → Canvas.toggle_module
//! Tree.Selected(Chart) → Shell → load chart + compute + 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_*)
//! ```
//!
//! Fase 1: las suscripciones están cableadas pero los handlers son
//! mínimos (logging + transición de modo). La pipeline real de cómputo
//! viene con la fase 3.
use gpui::{
Context, Entity, IntoElement, ParentElement, Render, SharedString, Styled, Window, div,
@@ -26,10 +20,10 @@ use gpui::{
};
use tahuantinsuyu_canvas::{
AstrologyCanvas, CanvasMode, ThumbnailItem, ThumbnailScope,
AstrologyCanvas, CanvasEvent, CanvasMode, ThumbnailItem, ThumbnailScope,
};
use tahuantinsuyu_engine::compute;
use tahuantinsuyu_model::TreeSelection;
use tahuantinsuyu_engine::{LayerKind, compute_at_offset};
use tahuantinsuyu_model::{Chart, TreeSelection};
use tahuantinsuyu_panel::{ControlPanel, PanelEvent};
use tahuantinsuyu_store::Store;
use tahuantinsuyu_tree::{TahuantinsuyuTree, TreeEvent};
@@ -46,6 +40,10 @@ 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,
}
impl Shell {
@@ -57,24 +55,29 @@ impl Shell {
let canvas = cx.new(AstrologyCanvas::new);
let panel = cx.new(ControlPanel::new);
// Tree → Shell: aplicar selección al canvas/panel.
cx.subscribe(&tree, |this: &mut Self, _, ev: &TreeEvent, cx| {
this.on_tree_event(ev, cx);
})
.detach();
// Panel → Shell: persistir y propagar al canvas.
cx.subscribe(&panel, |this: &mut Self, _, ev: &PanelEvent, cx| {
this.on_panel_event(ev, cx);
})
.detach();
cx.subscribe(&canvas, |this: &mut Self, _, ev: &CanvasEvent, cx| {
this.on_canvas_event(ev, cx);
})
.detach();
Self {
store,
bus,
tree,
canvas,
panel,
current_chart: None,
current_offset_minutes: 0,
}
}
@@ -83,10 +86,6 @@ impl Shell {
TreeEvent::Selected(s) => s,
TreeEvent::Opened(s) => s,
TreeEvent::HierarchyChanged => {
// El tree ya hizo refresh internamente; el canvas/panel
// se enteran cuando llegue una nueva Selección. Fase 3
// podría re-disparar la última selección para que el
// thumbnail grid se actualice si era una vista de grupo.
cx.notify();
return;
}
@@ -104,26 +103,15 @@ impl Shell {
return;
}
};
let kind = chart.kind;
let render = match compute(&chart) {
Ok(r) => r,
Err(e) => {
eprintln!("[shell] compute {}: {}", id, e);
return;
}
};
self.canvas.update(cx, |c, cx| {
c.set_mode(
CanvasMode::Wheel {
render: Box::new(render),
},
cx,
);
});
self.current_chart = Some(chart.clone());
self.current_offset_minutes = 0;
self.render_current(cx);
self.panel
.update(cx, |p, cx| p.set_active_kind(Some(kind), cx));
.update(cx, |p, cx| p.set_active_kind(Some(chart.kind), cx));
}
TreeSelection::Contact(id) => {
self.current_chart = None;
self.current_offset_minutes = 0;
let charts = self.store.list_charts(id).unwrap_or_default();
let items: Vec<ThumbnailItem> = charts
.into_iter()
@@ -146,6 +134,8 @@ impl Shell {
self.panel.update(cx, |p, cx| p.set_active_kind(None, cx));
}
TreeSelection::Group(id) => {
self.current_chart = None;
self.current_offset_minutes = 0;
let charts = self.store.charts_under_group(id).unwrap_or_default();
let items: Vec<ThumbnailItem> = charts
.into_iter()
@@ -170,18 +160,80 @@ impl Shell {
}
}
fn on_panel_event(&mut self, ev: &PanelEvent, cx: &mut Context<Self>) {
match ev {
PanelEvent::ModuleToggled { module_id, .. } => {
self.canvas
.update(cx, |c, cx| c.toggle_module(module_id, cx));
fn render_current(&mut self, cx: &mut Context<Self>) {
let Some(chart) = self.current_chart.as_ref() else {
return;
};
let render = match compute_at_offset(chart, self.current_offset_minutes) {
Ok(r) => r,
Err(e) => {
eprintln!(
"[shell] compute_at_offset {} (+{}min): {}",
chart.id, self.current_offset_minutes, e
);
return;
}
PanelEvent::ControlChanged { .. } => {
// Fase 4: aplicar config al canvas + persistir en store.
};
self.canvas.update(cx, |c, cx| {
c.set_mode(
CanvasMode::Wheel {
render: Box::new(render),
},
cx,
);
});
}
fn on_canvas_event(&mut self, ev: &CanvasEvent, cx: &mut Context<Self>) {
match ev {
CanvasEvent::TimeOffsetChanged(off) => {
self.current_offset_minutes = *off;
if self.current_chart.is_some() {
self.render_current(cx);
}
}
CanvasEvent::LayerVisibilityChanged { kind, visible } => {
// Sync el panel para que el toggle visual coincida con
// lo que disparó el hotkey en el canvas.
let key = match kind {
LayerKind::SignDial => "show_sign_dial",
LayerKind::Houses => "show_houses",
LayerKind::Aspects => "show_aspects",
LayerKind::Bodies => "show_bodies",
_ => return,
};
self.panel
.update(cx, |p, cx| p.set_toggle("natal", key, *visible, cx));
}
CanvasEvent::ChartRequested(_) => {
// Fase 5: doble click sobre un thumbnail abre la carta.
}
}
}
fn on_panel_event(&mut self, ev: &PanelEvent, cx: &mut Context<Self>) {
match ev {
PanelEvent::ControlChanged { module_id, key, value } => {
let visible = value.as_bool().unwrap_or(true);
if module_id == "natal" {
let kind = match key.as_str() {
"show_sign_dial" => Some(LayerKind::SignDial),
"show_houses" => Some(LayerKind::Houses),
"show_aspects" => Some(LayerKind::Aspects),
"show_bodies" => Some(LayerKind::Bodies),
_ => None,
};
if let Some(k) = kind {
self.canvas
.update(cx, |c, cx| c.set_layer_visible(k, visible, cx));
}
}
}
PanelEvent::ModuleToggled { .. } => {
// Fase 5: encender/apagar módulos enteros (Transit,
// Synastry, Uranian).
}
}
// Silenciar warnings de campos no leídos hasta que la fase 2
// cablee CRUD desde el tree.
let _ = (&self.store, &self.tree, &self.bus);
}
}
@@ -220,10 +272,7 @@ impl Render for Shell {
.border_color(theme.border)
.child(self.tree.clone());
let canvas_panel = div()
.flex_grow()
.h_full()
.child(self.canvas.clone());
let canvas_panel = div().flex_grow().h_full().child(self.canvas.clone());
let main_row = div()
.flex_grow()