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>
360797132ef9bc5e4d7f5056d4885be737d3dc39 · by sergio · 2026-05-17T10:15:09Z (5 months ago) · parent f4944218e · browse the tree at this commit
Semantic delta · 41
crates/apps/tahuantinsuyu/src/shell.rs
crates/modules/tahuantinsuyu/tahuantinsuyu-canvas/src/lib.rs
crates/modules/tahuantinsuyu/tahuantinsuyu-engine/src/bridge.rs
crates/modules/tahuantinsuyu/tahuantinsuyu-engine/src/lib.rs
crates/modules/tahuantinsuyu/tahuantinsuyu-modules/src/lib.rs
crates/modules/tahuantinsuyu/tahuantinsuyu-panel/src/lib.rs
diff --git a/crates/apps/tahuantinsuyu/src/shell.rs b/crates/apps/tahuantinsuyu/src/shell.rs
index f574683..631f9ab 100644
--- a/crates/apps/tahuantinsuyu/src/shell.rs
+++ b/crates/apps/tahuantinsuyu/src/shell.rs
@@ -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 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;
+ }
+ };
+ 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::ModuleToggled { module_id, .. } => {
- self.canvas
- .update(cx, |c, cx| c.toggle_module(module_id, cx));
+ 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::ControlChanged { .. } => {
- // Fase 4: aplicar config al canvas + persistir en store.
+ 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()
diff --git a/crates/modules/tahuantinsuyu/tahuantinsuyu-canvas/src/lib.rs b/crates/modules/tahuantinsuyu/tahuantinsuyu-canvas/src/lib.rs
index 0807a05..95b787a 100644
--- a/crates/modules/tahuantinsuyu/tahuantinsuyu-canvas/src/lib.rs
+++ b/crates/modules/tahuantinsuyu/tahuantinsuyu-canvas/src/lib.rs
@@ -13,22 +13,31 @@
//! un ascendente `asc`:
//!
//! ```text
-//! screen_angle_rad = π - (L - asc) · π/180 (más view_rotation)
+//! screen_angle_rad = π - (L - asc + view_rotation) · π/180
//! point = (cx + r·cos(θ), cy + r·sin(θ))
//! ```
//!
-//! El `+y` de canvas apunta para abajo, así que `+sin` lleva al sur del
-//! lienzo → la convención coincide con el chart estándar (IC abajo,
-//! MC arriba).
+//! ## Interacciones (fase 4)
+//!
+//! - **Drag en el aro exterior** (jog-dial perimetral): rota la rueda
+//! visualmente mientras dura el drag y, al soltar, traduce el delta
+//! angular a minutos (1° ≈ 4 min) y emite
+//! [`CanvasEvent::TimeOffsetChanged`]. El host (la app) recomputa la
+//! carta para el instante desplazado.
+//! - **Hotkeys**: `D`/`H`/`X`/`P` togglean SignDial/Houses/Aspects/
+//! Bodies. Click sobre el wheel le da focus al widget.
#![forbid(unsafe_code)]
#![warn(rust_2018_idioms)]
+use std::collections::HashMap;
use std::f32::consts::PI;
use gpui::{
- Bounds, Context, EventEmitter, Hsla, IntoElement, ParentElement, PathBuilder, Pixels, Render,
- SharedString, Styled, Window, canvas, div, hsla, point, prelude::*, px,
+ AppContext, Bounds, Context, EventEmitter, FocusHandle, Focusable, Hsla, IntoElement,
+ KeyDownEvent, MouseButton, MouseDownEvent, MouseMoveEvent, MouseUpEvent, ParentElement,
+ PathBuilder, Pixels, Point, Render, SharedString, Styled, Window, canvas, div, hsla, point,
+ prelude::*, px,
};
use tahuantinsuyu_engine::{Geometry, Layer, LayerKind, RenderModel};
@@ -42,8 +51,14 @@ use yahweh_theme::Theme;
#[derive(Clone, Debug)]
pub enum CanvasEvent {
+ /// Doble click sobre un thumbnail.
ChartRequested(ChartId),
+ /// Drag terminado: el offset acumulado de tiempo (en minutos)
+ /// cambió. El host debe recomputar el chart con este offset.
TimeOffsetChanged(i64),
+ /// El usuario togggleó una capa via hotkey — el panel debería
+ /// reflejarlo si quisiera mantenerse en sync.
+ LayerVisibilityChanged { kind: LayerKind, visible: bool },
}
// =====================================================================
@@ -75,13 +90,45 @@ pub struct ThumbnailItem {
pub preview: Option<RenderModel>,
}
-#[derive(Clone, Debug, Default)]
+/// Estado de un drag activo del jog-dial. `last_screen_angle_deg` se
+/// actualiza en cada `MouseMoveEvent`; `accumulated_delta_deg` lleva la
+/// rotación total desde que arrancó el drag (puede pasar de ±360°).
+#[derive(Clone, Debug)]
+struct JogDragState {
+ last_screen_angle_deg: f32,
+ accumulated_delta_deg: f32,
+}
+
+#[derive(Clone, Debug)]
pub struct CanvasState {
pub mode: CanvasMode,
- /// Rotación adicional manual en grados. `0.0` = el Asc cae a las 9.
+ /// Rotación visual transitoria durante un drag. Se resetea a `0` al
+ /// soltar — el render nuevo trae el `ascendant_deg` ya rotado.
pub view_rotation_deg: f32,
+ /// Offset acumulado en minutos. Persiste entre drags hasta que el
+ /// host lo resetee.
pub time_offset_minutes: i64,
- pub active_modules: std::collections::HashSet<String>,
+ /// Por-LayerKind: `true` = visible. Default = todo visible.
+ pub layer_visibility: HashMap<LayerKind, bool>,
+ drag_jog: Option<JogDragState>,
+}
+
+impl Default for CanvasState {
+ fn default() -> Self {
+ Self {
+ mode: CanvasMode::default(),
+ view_rotation_deg: 0.0,
+ time_offset_minutes: 0,
+ layer_visibility: HashMap::new(),
+ drag_jog: None,
+ }
+ }
+}
+
+impl CanvasState {
+ pub fn is_layer_visible(&self, kind: LayerKind) -> bool {
+ self.layer_visibility.get(&kind).copied().unwrap_or(true)
+ }
}
// =====================================================================
@@ -90,15 +137,23 @@ pub struct CanvasState {
pub struct AstrologyCanvas {
state: CanvasState,
+ focus_handle: FocusHandle,
}
impl EventEmitter<CanvasEvent> for AstrologyCanvas {}
+impl Focusable for AstrologyCanvas {
+ fn focus_handle(&self, _: &gpui::App) -> FocusHandle {
+ self.focus_handle.clone()
+ }
+}
+
impl AstrologyCanvas {
pub fn new(cx: &mut Context<Self>) -> Self {
cx.observe_global::<Theme>(|_, cx| cx.notify()).detach();
Self {
state: CanvasState::default(),
+ focus_handle: cx.focus_handle(),
}
}
@@ -111,43 +166,180 @@ impl AstrologyCanvas {
cx.notify();
}
- pub fn toggle_module(&mut self, module_id: &str, cx: &mut Context<Self>) {
- if !self.state.active_modules.remove(module_id) {
- self.state.active_modules.insert(module_id.to_string());
- }
+ pub fn set_layer_visible(&mut self, kind: LayerKind, visible: bool, cx: &mut Context<Self>) {
+ self.state.layer_visibility.insert(kind, visible);
cx.notify();
}
+ pub fn toggle_layer(&mut self, kind: LayerKind, cx: &mut Context<Self>) {
+ let current = self.state.is_layer_visible(kind);
+ self.set_layer_visible(kind, !current, cx);
+ cx.emit(CanvasEvent::LayerVisibilityChanged {
+ kind,
+ visible: !current,
+ });
+ }
+
+ pub fn reset_time_offset(&mut self, cx: &mut Context<Self>) {
+ if self.state.time_offset_minutes != 0 || self.state.view_rotation_deg != 0.0 {
+ self.state.time_offset_minutes = 0;
+ self.state.view_rotation_deg = 0.0;
+ cx.emit(CanvasEvent::TimeOffsetChanged(0));
+ cx.notify();
+ }
+ }
+
pub fn set_view_rotation(&mut self, deg: f32, cx: &mut Context<Self>) {
self.state.view_rotation_deg = deg.rem_euclid(360.0);
cx.notify();
}
+
+ // ----- Internos: handlers de jog-dial -----
+
+ fn on_jog_down(
+ &mut self,
+ position: Point<Pixels>,
+ bounds: Bounds<Pixels>,
+ cx: &mut Context<Self>,
+ ) {
+ let (cx_px, cy_px) = bounds_center(bounds);
+ let mx: f32 = position.x.into();
+ let my: f32 = position.y.into();
+ let dx = mx - cx_px;
+ let dy = my - cy_px;
+ let dist = (dx * dx + dy * dy).sqrt();
+ let r_outer = (WHEEL_SIZE - WHEEL_MARGIN * 2.0) / 2.0;
+ let radii = Radii::from_outer(r_outer);
+ // Aro de captura un poco más generoso que el anillo del dial.
+ if dist < radii.sign_inner * 0.95 || dist > radii.sign_outer * 1.10 {
+ return;
+ }
+ let angle = dy.atan2(dx).to_degrees();
+ self.state.drag_jog = Some(JogDragState {
+ last_screen_angle_deg: angle,
+ accumulated_delta_deg: 0.0,
+ });
+ }
+
+ fn on_jog_move(
+ &mut self,
+ position: Point<Pixels>,
+ bounds: Bounds<Pixels>,
+ cx: &mut Context<Self>,
+ ) {
+ let Some(jog) = self.state.drag_jog.as_mut() else {
+ return;
+ };
+ let (cx_px, cy_px) = bounds_center(bounds);
+ let mx: f32 = position.x.into();
+ let my: f32 = position.y.into();
+ let dx = mx - cx_px;
+ let dy = my - cy_px;
+ let angle = dy.atan2(dx).to_degrees();
+ let mut delta = angle - jog.last_screen_angle_deg;
+ // Normalizar a (-180, 180] para cruzar el wrap sin saltar.
+ if delta > 180.0 {
+ delta -= 360.0;
+ } else if delta < -180.0 {
+ delta += 360.0;
+ }
+ jog.accumulated_delta_deg += delta;
+ jog.last_screen_angle_deg = angle;
+ // Reflejo visual durante el drag (sin recomputar).
+ self.state.view_rotation_deg = jog.accumulated_delta_deg;
+ cx.notify();
+ }
+
+ fn on_jog_up(&mut self, cx: &mut Context<Self>) {
+ let Some(jog) = self.state.drag_jog.take() else {
+ return;
+ };
+ // 1° de arco ≈ 4 minutos de tiempo sideral (15°/hora).
+ // CW visual (delta negativa en nuestra convención) → tiempo
+ // hacia adelante.
+ let delta_minutes = (-jog.accumulated_delta_deg * 4.0) as i64;
+ if delta_minutes != 0 {
+ self.state.time_offset_minutes =
+ self.state.time_offset_minutes.saturating_add(delta_minutes);
+ // Snap visual: el shell recomputa con el nuevo offset y el
+ // render trae el ascendant rotado.
+ self.state.view_rotation_deg = 0.0;
+ cx.emit(CanvasEvent::TimeOffsetChanged(self.state.time_offset_minutes));
+ cx.notify();
+ } else {
+ self.state.view_rotation_deg = 0.0;
+ cx.notify();
+ }
+ }
+
+ fn on_key_down(&mut self, event: &KeyDownEvent, _w: &mut Window, cx: &mut Context<Self>) {
+ let key = event.keystroke.key.as_str();
+ let kind = match key {
+ "d" | "D" => LayerKind::SignDial,
+ "h" | "H" => LayerKind::Houses,
+ "x" | "X" => LayerKind::Aspects,
+ "p" | "P" => LayerKind::Bodies,
+ "r" | "R" => {
+ self.reset_time_offset(cx);
+ return;
+ }
+ _ => return,
+ };
+ self.toggle_layer(kind, cx);
+ }
}
// =====================================================================
-// Render
+// Geometría de pantalla
// =====================================================================
-/// Tamaño del cuadrado de la rueda en píxeles. Fijo para que glifos y
-/// geometría coincidan sin un round-trip de bounds. La rueda se centra
-/// en el panel del canvas; el resto del espacio queda como margen.
const WHEEL_SIZE: f32 = 580.0;
const WHEEL_MARGIN: f32 = 28.0;
+fn bounds_center(bounds: Bounds<Pixels>) -> (f32, f32) {
+ let ox: f32 = bounds.origin.x.into();
+ let oy: f32 = bounds.origin.y.into();
+ let bw: f32 = bounds.size.width.into();
+ let bh: f32 = bounds.size.height.into();
+ (ox + bw / 2.0, oy + bh / 2.0)
+}
+
+// =====================================================================
+// Render
+// =====================================================================
+
impl Render for AstrologyCanvas {
fn render(&mut self, _w: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let theme = Theme::global(cx).clone();
let palette = AstroPalette::for_theme(&theme);
+ let entity = cx.entity();
+ let focus = self.focus_handle.clone();
let body = match &self.state.mode {
CanvasMode::Empty => render_empty(&theme),
- CanvasMode::Wheel { render } => {
- render_wheel(&theme, &palette, render, self.state.view_rotation_deg)
- }
+ CanvasMode::Wheel { render } => render_wheel(
+ &theme,
+ &palette,
+ render,
+ self.state.view_rotation_deg,
+ self.state.time_offset_minutes,
+ &self.state.layer_visibility,
+ entity,
+ ),
CanvasMode::Thumbnails { items, .. } => render_thumbnails(&theme, items),
};
div()
+ .id("astrology-canvas-root")
+ .track_focus(&focus)
+ .key_context("AstrologyCanvas")
+ .on_key_down(cx.listener(Self::on_key_down))
+ .on_mouse_down(
+ MouseButton::Left,
+ cx.listener(|this, _, w, _cx| {
+ w.focus(&this.focus_handle);
+ }),
+ )
.size_full()
.bg(theme.bg_panel.clone())
.flex()
@@ -220,29 +412,37 @@ fn render_thumbnails(theme: &Theme, items: &[ThumbnailItem]) -> gpui::Div {
// Wheel
// =====================================================================
+#[allow(clippy::too_many_arguments)]
fn render_wheel(
theme: &Theme,
palette: &AstroPalette,
render: &RenderModel,
view_rotation_deg: f32,
+ time_offset_minutes: i64,
+ layer_visibility: &HashMap<LayerKind, bool>,
+ entity: gpui::Entity<AstrologyCanvas>,
) -> gpui::Div {
let asc = render.ascendant_deg;
let rot_offset = view_rotation_deg;
let cx_center = WHEEL_SIZE / 2.0;
let cy_center = WHEEL_SIZE / 2.0;
let r_outer = (WHEEL_SIZE - WHEEL_MARGIN * 2.0) / 2.0;
-
let radii = Radii::from_outer(r_outer);
- // --- Canvas element con todo el trazo ---
+ let visible = layer_visibility.clone();
+
+ // --- Canvas element con todo el trazo + jog-dial drag ---
let palette_paint = palette.clone();
let theme_paint = theme.clone();
let layers_paint: Vec<Layer> = render.layers.clone();
let asc_for_paint = asc;
let mc_for_paint = render.midheaven_deg;
+ let visibility_for_paint = visible.clone();
+ let entity_for_canvas = entity.clone();
let canvas_element = canvas(
move |_b: Bounds<Pixels>, _w, _cx| (),
move |bounds: Bounds<Pixels>, _, window, _| {
+ // Painting de la rueda.
paint_wheel(
bounds,
window,
@@ -253,83 +453,113 @@ fn render_wheel(
mc_for_paint,
rot_offset,
radii,
+ &visibility_for_paint,
);
+
+ // Handlers de mouse para el jog-dial — se registran cada
+ // frame contra el window; GPUI los reemplaza al re-renderear.
+ let entity_d = entity_for_canvas.clone();
+ window.on_mouse_event(move |ev: &MouseDownEvent, _, _w, cx| {
+ if ev.button != MouseButton::Left {
+ return;
+ }
+ if !bounds.contains(&ev.position) {
+ return;
+ }
+ entity_d.update(cx, |this, cx| this.on_jog_down(ev.position, bounds, cx));
+ });
+ let entity_m = entity_for_canvas.clone();
+ window.on_mouse_event(move |ev: &MouseMoveEvent, _, _w, cx| {
+ if !ev.dragging() {
+ return;
+ }
+ entity_m.update(cx, |this, cx| this.on_jog_move(ev.position, bounds, cx));
+ });
+ let entity_u = entity_for_canvas.clone();
+ window.on_mouse_event(move |_: &MouseUpEvent, _, _w, cx| {
+ entity_u.update(cx, |this, cx| this.on_jog_up(cx));
+ });
},
)
.absolute()
.w(px(WHEEL_SIZE))
.h(px(WHEEL_SIZE));
- // --- Glyphs como divs absolutos (text-rendering nativo) ---
let mut wheel = div()
.relative()
.w(px(WHEEL_SIZE))
.h(px(WHEEL_SIZE))
.child(canvas_element);
- // Sign glyphs en el centro de cada sector zodiacal.
- let sign_ring_mid = (radii.sign_outer + radii.sign_inner) / 2.0;
- for layer in &render.layers {
- if matches!(layer.kind, LayerKind::SignDial) {
- for g in &layer.glyphs {
- let (x, y) = polar_to_screen(g.deg, asc, rot_offset, sign_ring_mid);
- let color = element_color_for_sign(palette, &g.symbol);
- wheel = wheel.child(centered_glyph(
- cx_center + x,
- cy_center + y,
- 20.0,
- 18.0,
- sign_unicode(&g.symbol).into(),
- color,
- ));
- }
- }
- }
-
- // House numbers cerca de cada cusp.
- let house_label_r = (radii.houses_outer + radii.houses_inner) / 2.0;
- for layer in &render.layers {
- if matches!(layer.kind, LayerKind::Houses) {
- for g in &layer.glyphs {
- let (x, y) = polar_to_screen(g.deg, asc, rot_offset, house_label_r);
- if let Some(h) = g.house {
+ // Sign glyphs.
+ if visible.get(&LayerKind::SignDial).copied().unwrap_or(true) {
+ let sign_ring_mid = (radii.sign_outer + radii.sign_inner) / 2.0;
+ for layer in &render.layers {
+ if matches!(layer.kind, LayerKind::SignDial) {
+ for g in &layer.glyphs {
+ let (x, y) = polar_to_screen(g.deg, asc, rot_offset, sign_ring_mid);
+ let color = element_color_for_sign(palette, &g.symbol);
wheel = wheel.child(centered_glyph(
cx_center + x,
cy_center + y,
- 16.0,
- 10.0,
- format!("{}", h).into(),
- palette.house_cusp,
+ 20.0,
+ 18.0,
+ sign_unicode(&g.symbol).into(),
+ color,
));
}
}
}
}
- // Planet glyphs sobre el ring de cuerpos.
- for layer in &render.layers {
- if matches!(layer.kind, LayerKind::Bodies) {
- for g in &layer.glyphs {
- let (x, y) = polar_to_screen(g.deg, asc, rot_offset, radii.bodies);
- let color = planet_color(palette, &g.symbol);
- let glyph_text = if g.retrograde {
- format!("{}ᴿ", planet_unicode(&g.symbol))
- } else {
- planet_unicode(&g.symbol).into()
- };
- wheel = wheel.child(centered_glyph(
- cx_center + x,
- cy_center + y,
- 24.0,
- 18.0,
- glyph_text.into(),
- color,
- ));
+ // House numbers.
+ if visible.get(&LayerKind::Houses).copied().unwrap_or(true) {
+ let house_label_r = (radii.houses_outer + radii.houses_inner) / 2.0;
+ for layer in &render.layers {
+ if matches!(layer.kind, LayerKind::Houses) {
+ for g in &layer.glyphs {
+ let (x, y) = polar_to_screen(g.deg, asc, rot_offset, house_label_r);
+ if let Some(h) = g.house {
+ wheel = wheel.child(centered_glyph(
+ cx_center + x,
+ cy_center + y,
+ 16.0,
+ 10.0,
+ format!("{}", h).into(),
+ palette.house_cusp,
+ ));
+ }
+ }
+ }
+ }
+ }
+
+ // Planet glyphs.
+ if visible.get(&LayerKind::Bodies).copied().unwrap_or(true) {
+ for layer in &render.layers {
+ if matches!(layer.kind, LayerKind::Bodies) {
+ for g in &layer.glyphs {
+ let (x, y) = polar_to_screen(g.deg, asc, rot_offset, radii.bodies);
+ let color = planet_color(palette, &g.symbol);
+ let glyph_text = if g.retrograde {
+ format!("{}ᴿ", planet_unicode(&g.symbol))
+ } else {
+ planet_unicode(&g.symbol).into()
+ };
+ wheel = wheel.child(centered_glyph(
+ cx_center + x,
+ cy_center + y,
+ 24.0,
+ 18.0,
+ glyph_text.into(),
+ color,
+ ));
+ }
}
}
}
- // --- Composición final con título arriba ---
+ // --- Header + footer + indicador de tiempo ---
let header = div()
.flex()
.flex_col()
@@ -351,16 +581,38 @@ fn render_wheel(
} else {
header
};
+
+ let offset_label = format_offset(time_offset_minutes);
+ let offset_color = if time_offset_minutes == 0 {
+ theme.fg_disabled
+ } else {
+ palette.angle_highlight
+ };
let footer = div()
- .text_size(px(10.0))
- .text_color(theme.fg_disabled)
- .child(SharedString::from(format!(
- "Asc {:.1}° MC {:.1}° · {} capas · {} ms",
- render.ascendant_deg,
- render.midheaven_deg,
- render.layers.len(),
- render.compute_ms,
- )));
+ .flex()
+ .flex_row()
+ .gap(px(10.0))
+ .child(
+ div()
+ .text_size(px(10.0))
+ .text_color(theme.fg_disabled)
+ .child(SharedString::from(format!(
+ "Asc {:.1}° · MC {:.1}° · {} ms",
+ render.ascendant_deg, render.midheaven_deg, render.compute_ms,
+ ))),
+ )
+ .child(
+ div()
+ .text_size(px(10.0))
+ .text_color(offset_color)
+ .child(SharedString::from(offset_label)),
+ )
+ .child(
+ div()
+ .text_size(px(10.0))
+ .text_color(theme.fg_disabled)
+ .child("[D]ial [H]ouses as[X]pects [P]lanets [R]eset"),
+ );
div()
.flex()
@@ -372,6 +624,24 @@ fn render_wheel(
.child(footer)
}
+fn format_offset(minutes: i64) -> String {
+ if minutes == 0 {
+ return "⏱ ahora".to_string();
+ }
+ let sign = if minutes > 0 { '+' } else { '-' };
+ let m = minutes.unsigned_abs();
+ let days = m / (60 * 24);
+ let hours = (m / 60) % 24;
+ let mins = m % 60;
+ if days > 0 {
+ format!("⏱ {}{}d {:02}h {:02}m", sign, days, hours, mins)
+ } else if hours > 0 {
+ format!("⏱ {}{:02}h {:02}m", sign, hours, mins)
+ } else {
+ format!("⏱ {}{:02}m", sign, mins)
+ }
+}
+
// =====================================================================
// Painting
// =====================================================================
@@ -410,143 +680,144 @@ fn paint_wheel(
midheaven_deg: f32,
rot_offset_deg: f32,
radii: Radii,
+ visibility: &HashMap<LayerKind, bool>,
) {
- let ox: f32 = bounds.origin.x.into();
- let oy: f32 = bounds.origin.y.into();
- let bw: f32 = bounds.size.width.into();
- let bh: f32 = bounds.size.height.into();
- let cx = ox + bw / 2.0;
- let cy = oy + bh / 2.0;
-
- // 1. Sectores del zodíaco coloreados por elemento.
- paint_sign_sectors(window, cx, cy, &radii, palette, ascendant_deg, rot_offset_deg);
-
- // 2. Anillos (outer + inner del sign dial, houses outer, body outer).
- stroke_circle(window, cx, cy, radii.sign_outer, 1.5, palette.dial_ring);
- stroke_circle(window, cx, cy, radii.sign_inner, 1.0, palette.dial_ring);
- stroke_circle(
- window,
- cx,
- cy,
- radii.houses_inner,
- 0.8,
- with_alpha(palette.house_cusp, 0.6),
- );
-
- // 3. Líneas de cusp del zodíaco (cada 30° desde Aries 0°).
- for i in 0..12 {
- let lon = (i as f32) * 30.0;
- let color = if i == 0 {
- palette.angle_highlight
- } else {
- palette.dial_ring
- };
+ let (cx, cy) = bounds_center(bounds);
+ let _ = theme;
+ let show = |k: LayerKind| visibility.get(&k).copied().unwrap_or(true);
+
+ // 1. Sectores zodiacales (parte del SignDial layer).
+ if show(LayerKind::SignDial) {
+ paint_sign_sectors(window, cx, cy, &radii, palette, ascendant_deg, rot_offset_deg);
+
+ // Anillos del dial.
+ stroke_circle(window, cx, cy, radii.sign_outer, 1.5, palette.dial_ring);
+ stroke_circle(window, cx, cy, radii.sign_inner, 1.0, palette.dial_ring);
+
+ // Cusps zodiacales cada 30°.
+ for i in 0..12 {
+ let lon = (i as f32) * 30.0;
+ let color = palette.dial_ring;
+ paint_radial_line(
+ window,
+ cx,
+ cy,
+ lon,
+ ascendant_deg,
+ rot_offset_deg,
+ radii.sign_inner,
+ radii.sign_outer,
+ color,
+ 1.0,
+ );
+ }
+ }
+
+ // 2. Casas — cusps radiales + énfasis Asc/IC/Desc/MC.
+ if show(LayerKind::Houses) {
+ stroke_circle(
+ window,
+ cx,
+ cy,
+ radii.houses_inner,
+ 0.8,
+ with_alpha(palette.house_cusp, 0.6),
+ );
+
+ for layer in layers {
+ if matches!(layer.kind, LayerKind::Houses) {
+ if let Geometry::Ring { cusps_deg } = &layer.geometry {
+ for (i, c) in cusps_deg.iter().enumerate() {
+ let is_angle = i == 0 || i == 3 || i == 6 || i == 9;
+ let color = if is_angle {
+ palette.angle_highlight
+ } else {
+ with_alpha(palette.house_cusp, 0.7)
+ };
+ let width = if is_angle { 2.0 } else { 0.8 };
+ paint_radial_line(
+ window,
+ cx,
+ cy,
+ *c,
+ ascendant_deg,
+ rot_offset_deg,
+ radii.houses_inner,
+ radii.houses_outer,
+ color,
+ width,
+ );
+ }
+ }
+ }
+ }
+
+ // Asc + MC extendidos hasta el centro con opacidad sutil.
+ paint_radial_line(
+ window,
+ cx,
+ cy,
+ ascendant_deg,
+ ascendant_deg,
+ rot_offset_deg,
+ 0.0,
+ radii.houses_outer,
+ with_alpha(palette.angle_highlight, 0.35),
+ 1.0,
+ );
paint_radial_line(
window,
cx,
cy,
- lon,
+ midheaven_deg,
ascendant_deg,
rot_offset_deg,
- radii.sign_inner,
- radii.sign_outer,
- color,
+ 0.0,
+ radii.houses_outer,
+ with_alpha(palette.angle_highlight, 0.35),
1.0,
);
}
- // 4. Casas: cusps radiales + énfasis Asc / MC.
- for layer in layers {
- if matches!(layer.kind, LayerKind::Houses) {
- if let Geometry::Ring { cusps_deg } = &layer.geometry {
- for (i, c) in cusps_deg.iter().enumerate() {
- let is_angle = i == 0 || i == 3 || i == 6 || i == 9;
- let color = if is_angle {
- palette.angle_highlight
- } else {
- with_alpha(palette.house_cusp, 0.7)
- };
- let width = if is_angle { 2.0 } else { 0.8 };
- paint_radial_line(
- window,
- cx,
- cy,
- *c,
- ascendant_deg,
- rot_offset_deg,
- radii.houses_inner,
- radii.houses_outer,
- color,
- width,
- );
+ // 3. Aspectos.
+ if show(LayerKind::Aspects) {
+ for layer in layers {
+ if matches!(layer.kind, LayerKind::Aspects) {
+ if let Geometry::Lines(segs) = &layer.geometry {
+ for seg in segs {
+ let color = aspect_color(palette, &seg.kind);
+ let color = with_alpha(color, color.a * seg.opacity);
+ paint_aspect_line(
+ window,
+ cx,
+ cy,
+ seg.from_deg,
+ seg.to_deg,
+ ascendant_deg,
+ rot_offset_deg,
+ radii.aspects,
+ color,
+ );
+ }
}
}
}
}
- // 5. Énfasis Asc + MC extendido hasta el centro (línea fina).
- paint_radial_line(
- window,
- cx,
- cy,
- ascendant_deg,
- ascendant_deg,
- rot_offset_deg,
- 0.0,
- radii.houses_outer,
- with_alpha(palette.angle_highlight, 0.35),
- 1.0,
- );
- paint_radial_line(
- window,
- cx,
- cy,
- midheaven_deg,
- ascendant_deg,
- rot_offset_deg,
- 0.0,
- radii.houses_outer,
- with_alpha(palette.angle_highlight, 0.35),
- 1.0,
- );
-
- // 6. Aspectos.
- for layer in layers {
- if matches!(layer.kind, LayerKind::Aspects) {
- if let Geometry::Lines(segs) = &layer.geometry {
- for seg in segs {
- let color = aspect_color(palette, &seg.kind);
- let color = with_alpha(color, color.a * seg.opacity);
- paint_aspect_line(
- window,
- cx,
- cy,
- seg.from_deg,
- seg.to_deg,
- ascendant_deg,
- rot_offset_deg,
- radii.aspects,
- color,
- );
+ // 4. Dots de cuerpos.
+ if show(LayerKind::Bodies) {
+ let dot_r = (radii.sign_outer * 0.018).max(2.0);
+ for layer in layers {
+ if matches!(layer.kind, LayerKind::Bodies) {
+ for g in &layer.glyphs {
+ let color = planet_color(palette, &g.symbol);
+ let (x, y) =
+ polar_to_screen(g.deg, ascendant_deg, rot_offset_deg, radii.bodies);
+ fill_circle(window, cx + x, cy + y, dot_r, color);
}
}
}
}
-
- // 7. Cuerpos: pequeño dot detrás del glifo.
- let dot_r = (radii.sign_outer * 0.018).max(2.0);
- for layer in layers {
- if matches!(layer.kind, LayerKind::Bodies) {
- for g in &layer.glyphs {
- let color = planet_color(palette, &g.symbol);
- let (x, y) = polar_to_screen(g.deg, ascendant_deg, rot_offset_deg, radii.bodies);
- fill_circle(window, cx + x, cy + y, dot_r, color);
- }
- }
- }
-
- // 8. Marco exterior del lienzo (sutil).
- let _ = theme;
}
fn paint_sign_sectors(
@@ -558,10 +829,6 @@ fn paint_sign_sectors(
ascendant_deg: f32,
rot_offset_deg: f32,
) {
- // Cada sector cubre 30° de longitud zodiacal entre `sign_inner` y
- // `sign_outer`. Lo aproximamos con polígonos para no depender de
- // `arc_to` (que requiere `Vector<Pixels>`; los polígonos son
- // suficientemente suaves a este radio).
const SUBDIVISIONS: usize = 18;
for i in 0..12 {
let lon_start = (i as f32) * 30.0;
@@ -573,17 +840,14 @@ fn paint_sign_sectors(
let (x0, y0) = polar_to_screen(lon_start, ascendant_deg, rot_offset_deg, radii.sign_inner);
builder.move_to(point(px(cx + x0), px(cy + y0)));
- // Borde interno (lon_start → lon_end), N subdivisiones.
for k in 1..=SUBDIVISIONS {
let t = lon_start + (lon_end - lon_start) * (k as f32) / (SUBDIVISIONS as f32);
let (x, y) = polar_to_screen(t, ascendant_deg, rot_offset_deg, radii.sign_inner);
builder.line_to(point(px(cx + x), px(cy + y)));
}
- // Salto al borde externo en lon_end.
let (xe, ye) = polar_to_screen(lon_end, ascendant_deg, rot_offset_deg, radii.sign_outer);
builder.line_to(point(px(cx + xe), px(cy + ye)));
- // Borde externo de lon_end → lon_start (al revés).
for k in (0..SUBDIVISIONS).rev() {
let t = lon_start + (lon_end - lon_start) * (k as f32) / (SUBDIVISIONS as f32);
let (x, y) = polar_to_screen(t, ascendant_deg, rot_offset_deg, radii.sign_outer);
@@ -596,14 +860,7 @@ fn paint_sign_sectors(
}
}
-fn stroke_circle(
- window: &mut Window,
- cx: f32,
- cy: f32,
- r: f32,
- width: f32,
- color: Hsla,
-) {
+fn stroke_circle(window: &mut Window, cx: f32, cy: f32, r: f32, width: f32, color: Hsla) {
const SEGMENTS: usize = 96;
let mut builder = PathBuilder::stroke(px(width));
for i in 0..=SEGMENTS {
@@ -683,19 +940,15 @@ fn paint_aspect_line(
}
// =====================================================================
-// Geometry helpers
+// Helpers
// =====================================================================
-/// Mapea una longitud eclíptica + ascendente + rotación adicional → (x, y)
-/// **relativos al centro del lienzo** (positivo hacia derecha/abajo).
fn polar_to_screen(
longitude_deg: f32,
ascendant_deg: f32,
rot_offset_deg: f32,
radius: f32,
) -> (f32, f32) {
- // Convención: el Asc cae a las 9 (θ=π). A más longitud, más
- // contrarreloj visual → θ decrece.
let deg = 180.0 - (longitude_deg - ascendant_deg + rot_offset_deg);
let rad = deg * PI / 180.0;
(radius * rad.cos(), radius * rad.sin())
@@ -727,10 +980,6 @@ fn with_alpha(c: Hsla, a: f32) -> Hsla {
hsla(c.h, c.s, c.l, a.clamp(0.0, 1.0))
}
-// =====================================================================
-// Symbol → unicode / theme
-// =====================================================================
-
fn sign_unicode(name: &str) -> &'static str {
match name {
"aries" => "♈",
@@ -832,9 +1081,9 @@ fn aspect_color(p: &AstroPalette, kind: &str) -> Hsla {
p.aspect(k)
}
-// =====================================================================
-// Adendum: fallback color cuando la paleta no tiene match
-// =====================================================================
+trait AstroPaletteExt {
+ fn fg_text_fallback(&self) -> Hsla;
+}
impl AstroPaletteExt for AstroPalette {
fn fg_text_fallback(&self) -> Hsla {
@@ -845,8 +1094,3 @@ impl AstroPaletteExt for AstroPalette {
}
}
}
-
-trait AstroPaletteExt {
- fn fg_text_fallback(&self) -> Hsla;
-}
-
diff --git a/crates/modules/tahuantinsuyu/tahuantinsuyu-engine/src/bridge.rs b/crates/modules/tahuantinsuyu/tahuantinsuyu-engine/src/bridge.rs
index 0c3c26c..4929895 100644
--- a/crates/modules/tahuantinsuyu/tahuantinsuyu-engine/src/bridge.rs
+++ b/crates/modules/tahuantinsuyu/tahuantinsuyu-engine/src/bridge.rs
@@ -173,12 +173,12 @@ fn aspect_kind_id(k: EAspectKind) -> &'static str {
// compute()
// =====================================================================
-pub fn compute(chart: &Chart) -> Result<RenderModel, EngineError> {
+pub fn compute_at_offset(chart: &Chart, offset_minutes: i64) -> Result<RenderModel, EngineError> {
let t0 = Instant::now();
chart.validate()?;
let bd = &chart.birth_data;
- let instant = ESInstant::from_civil_local(
+ let base_instant = ESInstant::from_civil_local(
bd.year,
u8::try_from(bd.month).map_err(|_| {
EngineError::Eternal(format!("mes fuera de u8: {}", bd.month))
@@ -197,6 +197,17 @@ pub fn compute(chart: &Chart) -> Result<RenderModel, EngineError> {
)
.map_err(|e| EngineError::Eternal(format!("Instant::from_civil_local: {:?}", e)))?;
+ // Aplicar el offset (en minutos) sumando segundos al UTC y
+ // reconstruyendo el `Instant`. `UTC::add_seconds` está disponible
+ // pero opera sobre la representación interna; el `Instant` la
+ // re-envuelve vía `from_utc`.
+ let instant = if offset_minutes == 0 {
+ base_instant
+ } else {
+ let shifted_utc = base_instant.utc().add_seconds((offset_minutes as f64) * 60.0);
+ ESInstant::from_utc(shifted_utc)
+ };
+
let observer = Observer::from_degrees(bd.latitude_deg, bd.longitude_deg, bd.altitude_m);
let mut birth_e = BirthData::new(instant, observer);
diff --git a/crates/modules/tahuantinsuyu/tahuantinsuyu-engine/src/lib.rs b/crates/modules/tahuantinsuyu/tahuantinsuyu-engine/src/lib.rs
index 19be94f..51118d6 100644
--- a/crates/modules/tahuantinsuyu/tahuantinsuyu-engine/src/lib.rs
+++ b/crates/modules/tahuantinsuyu/tahuantinsuyu-engine/src/lib.rs
@@ -154,12 +154,21 @@ pub enum EngineError {
/// Computa el RenderModel real contra eternal-astrology si el feature
/// está prendido; sino cae al mock.
pub fn compute(chart: &Chart) -> Result<RenderModel, EngineError> {
+ compute_at_offset(chart, 0)
+}
+
+/// Variante con offset temporal en minutos sobre el instante del chart.
+/// Útil para time-scrubbing: el jog-dial del canvas pasa el offset
+/// acumulado y la engine recompone toda la pipeline (Asc, casas,
+/// posiciones planetarias, aspectos) para ese instante desplazado.
+pub fn compute_at_offset(chart: &Chart, offset_minutes: i64) -> Result<RenderModel, EngineError> {
#[cfg(feature = "eternal-bridge")]
{
- bridge::compute(chart)
+ bridge::compute_at_offset(chart, offset_minutes)
}
#[cfg(not(feature = "eternal-bridge"))]
{
+ let _ = offset_minutes;
Ok(compute_mock(chart))
}
}
diff --git a/crates/modules/tahuantinsuyu/tahuantinsuyu-modules/src/lib.rs b/crates/modules/tahuantinsuyu/tahuantinsuyu-modules/src/lib.rs
index 3e4c6b6..228503b 100644
--- a/crates/modules/tahuantinsuyu/tahuantinsuyu-modules/src/lib.rs
+++ b/crates/modules/tahuantinsuyu/tahuantinsuyu-modules/src/lib.rs
@@ -180,22 +180,28 @@ pub mod natal {
fn controls(&self) -> Vec<Control> {
vec![
Control::Toggle {
- key: "show_ecliptic".into(),
- label: "Eclíptica".into(),
+ key: "show_sign_dial".into(),
+ label: "Dial zodiacal".into(),
default: true,
- hotkey: Some("E".into()),
+ hotkey: Some("D".into()),
},
Control::Toggle {
- key: "show_ascensional".into(),
- label: "Ascensional".into(),
- default: false,
- hotkey: Some("A".into()),
+ 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: None,
+ hotkey: Some("X".into()),
+ },
+ Control::Toggle {
+ key: "show_bodies".into(),
+ label: "Cuerpos".into(),
+ default: true,
+ hotkey: Some("P".into()),
},
Control::Slider {
key: "harmonic".into(),
diff --git a/crates/modules/tahuantinsuyu/tahuantinsuyu-panel/src/lib.rs b/crates/modules/tahuantinsuyu/tahuantinsuyu-panel/src/lib.rs
index 5cc5e65..b6e11fe 100644
--- a/crates/modules/tahuantinsuyu/tahuantinsuyu-panel/src/lib.rs
+++ b/crates/modules/tahuantinsuyu/tahuantinsuyu-panel/src/lib.rs
@@ -3,22 +3,29 @@
//! Lee los módulos disponibles para la carta activa (vía
//! [`tahuantinsuyu_modules::Registry::for_kind`]) y pinta sus
//! [`Control`]s como toggles / sliders / selects. Cada cambio emite
-//! [`PanelEvent`] que la app traduce a mutaciones de `ModuleState` en
-//! la store y a `toggle_module` sobre el canvas.
+//! [`PanelEvent`] que la app traduce a mutaciones de visibilidad sobre
+//! el canvas (fase 4) y eventualmente a `ModuleState` en la store.
//!
-//! Fase 1: render placeholder con el listado de módulos disponibles y
-//! sus controles, sin handlers cableados todavía (la interacción real
-//! viene con la fase 4).
+//! ## Estado interno
+//!
+//! El panel mantiene un cache `toggle_state` con los valores actuales
+//! de los toggles por (module_id, key). Inicializa desde los defaults
+//! declarados por el módulo y se actualiza con cada click. Los sliders
+//! / selects todavía no son interactivos en fase 4 — quedan como
+//! display de "valor default".
#![forbid(unsafe_code)]
#![warn(rust_2018_idioms)]
+use std::collections::HashMap;
+
use gpui::{
- Context, EventEmitter, IntoElement, Render, SharedString, Window, div, prelude::*, px,
+ ClickEvent, Context, EventEmitter, IntoElement, ParentElement, Render, SharedString, Styled,
+ Window, div, prelude::*, px,
};
use tahuantinsuyu_model::ChartKind;
-use tahuantinsuyu_modules::{Control, Registry};
+use tahuantinsuyu_modules::{Control, Module, Registry};
use yahweh_theme::Theme;
// =====================================================================
@@ -27,9 +34,7 @@ use yahweh_theme::Theme;
#[derive(Clone, Debug)]
pub enum PanelEvent {
- /// Toggle on/off de un módulo entero.
ModuleToggled { module_id: String, enabled: bool },
- /// Cambio de un control puntual.
ControlChanged {
module_id: String,
key: String,
@@ -42,9 +47,11 @@ pub enum PanelEvent {
// =====================================================================
pub struct ControlPanel {
- /// Módulo activo a mostrar. `None` ⇒ no hay carta seleccionada,
- /// pintamos un placeholder.
active_kind: Option<ChartKind>,
+ /// Cache de toggles por (module_id, key). Se popula lazy desde los
+ /// defaults la primera vez que se renderea un kind.
+ toggle_state: HashMap<(String, String), bool>,
+ registry: Registry,
}
impl EventEmitter<PanelEvent> for ControlPanel {}
@@ -52,19 +59,77 @@ impl EventEmitter<PanelEvent> for ControlPanel {}
impl ControlPanel {
pub fn new(cx: &mut Context<Self>) -> Self {
cx.observe_global::<Theme>(|_, cx| cx.notify()).detach();
- Self { active_kind: None }
+ Self {
+ active_kind: None,
+ toggle_state: HashMap::new(),
+ registry: Registry::with_builtins(),
+ }
}
pub fn set_active_kind(&mut self, kind: Option<ChartKind>, cx: &mut Context<Self>) {
+ // Si cambia el kind, inicializamos defaults para sus módulos.
+ if self.active_kind != kind {
+ if let Some(k) = kind {
+ for m in self.registry.for_kind(k) {
+ for c in m.controls() {
+ if let Control::Toggle { key, default, .. } = c {
+ self.toggle_state
+ .entry((m.id().to_string(), key))
+ .or_insert(default);
+ }
+ }
+ }
+ }
+ }
self.active_kind = kind;
cx.notify();
}
+
+ /// Setea un toggle desde afuera (sin emitir evento). Útil cuando el
+ /// canvas se autotoggleó via hotkey y queremos sincronizar el panel.
+ pub fn set_toggle(&mut self, module_id: &str, key: &str, value: bool, cx: &mut Context<Self>) {
+ self.toggle_state
+ .insert((module_id.to_string(), key.to_string()), value);
+ cx.notify();
+ }
+
+ fn on_toggle_click(&mut self, module_id: String, key: String, cx: &mut Context<Self>) {
+ let entry = self
+ .toggle_state
+ .entry((module_id.clone(), key.clone()))
+ .or_insert(true);
+ *entry = !*entry;
+ let new_val = *entry;
+ cx.emit(PanelEvent::ControlChanged {
+ module_id,
+ key,
+ value: serde_json::Value::Bool(new_val),
+ });
+ cx.notify();
+ }
}
impl Render for ControlPanel {
fn render(&mut self, _w: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let theme = Theme::global(cx).clone();
- let registry = Registry::with_builtins();
+ // Snapshot de los módulos a renderear — borrowing isssues si
+ // dejáramos el iterador vivo mientras mutamos en el closure.
+ let modules: Vec<(String, String, String, Vec<Control>)> = match self.active_kind {
+ Some(k) => self
+ .registry
+ .for_kind(k)
+ .iter()
+ .map(|m| {
+ (
+ m.id().to_string(),
+ m.label().to_string(),
+ m.description().to_string(),
+ m.controls(),
+ )
+ })
+ .collect(),
+ None => Vec::new(),
+ };
let header = div()
.h(px(28.0))
@@ -81,12 +146,16 @@ impl Render for ControlPanel {
.text_color(theme.fg_muted)
.child("Panel de control"),
)
- .child(div().ml_auto().text_size(px(10.0)).text_color(theme.fg_disabled).child(
- match self.active_kind {
- Some(k) => SharedString::from(format!("{:?}", k)),
- None => SharedString::from("sin carta activa"),
- },
- ));
+ .child(
+ div()
+ .ml_auto()
+ .text_size(px(10.0))
+ .text_color(theme.fg_disabled)
+ .child(match self.active_kind {
+ Some(k) => SharedString::from(format!("{:?}", k)),
+ None => SharedString::from("sin carta activa"),
+ }),
+ );
let mut body = div()
.flex()
@@ -96,17 +165,17 @@ impl Render for ControlPanel {
.px(px(12.0))
.py(px(8.0));
- if let Some(kind) = self.active_kind {
- for m in registry.for_kind(kind) {
- body = body.child(render_module(&theme, m));
- }
- } else {
+ if modules.is_empty() {
body = body.child(
div()
.text_size(px(11.0))
.text_color(theme.fg_disabled)
.child("Seleccioná una carta para ver sus controles."),
);
+ } else {
+ for (id, label, desc, controls) in &modules {
+ body = body.child(self.render_module(&theme, id, label, desc, controls, cx));
+ }
}
div()
@@ -119,63 +188,149 @@ impl Render for ControlPanel {
}
}
-fn render_module(theme: &Theme, m: &dyn tahuantinsuyu_modules::Module) -> gpui::Div {
- let header = div()
- .flex()
- .flex_row()
- .items_center()
- .gap(px(6.0))
- .child(
- div()
- .text_size(px(12.0))
- .text_color(theme.fg_text)
- .child(SharedString::from(m.label())),
- )
- .child(
- div()
- .text_size(px(10.0))
- .text_color(theme.fg_muted)
- .child(SharedString::from(m.description())),
- );
-
- let mut controls = div().flex().flex_col().gap(px(4.0));
- for c in m.controls() {
- controls = controls.child(render_control(theme, &c));
- }
+impl ControlPanel {
+ fn render_module(
+ &self,
+ theme: &Theme,
+ module_id: &str,
+ label: &str,
+ description: &str,
+ controls: &[Control],
+ cx: &mut Context<Self>,
+ ) -> gpui::Div {
+ let header = div()
+ .flex()
+ .flex_col()
+ .gap(px(2.0))
+ .child(
+ div()
+ .text_size(px(12.0))
+ .text_color(theme.fg_text)
+ .child(SharedString::from(label.to_string())),
+ )
+ .child(
+ div()
+ .text_size(px(10.0))
+ .text_color(theme.fg_muted)
+ .child(SharedString::from(description.to_string())),
+ );
- div()
- .min_w(px(220.0))
- .p(px(8.0))
- .rounded(px(6.0))
- .bg(theme.bg_panel_alt.clone())
- .border_1()
- .border_color(theme.border)
- .flex()
- .flex_col()
- .gap(px(6.0))
- .child(header)
- .child(controls)
-}
+ let mut body = div().flex().flex_col().gap(px(4.0));
+ for c in controls {
+ body = body.child(self.render_control(theme, module_id, c, cx));
+ }
+
+ div()
+ .min_w(px(240.0))
+ .p(px(8.0))
+ .rounded(px(6.0))
+ .bg(theme.bg_panel_alt.clone())
+ .border_1()
+ .border_color(theme.border)
+ .flex()
+ .flex_col()
+ .gap(px(6.0))
+ .child(header)
+ .child(body)
+ }
-fn render_control(theme: &Theme, c: &Control) -> gpui::Div {
- match c {
- Control::Toggle { label, default, hotkey, .. } => {
- let dot_color = if *default {
- theme.accent
- } else {
- theme.fg_disabled
- };
- div()
+ fn render_control(
+ &self,
+ theme: &Theme,
+ module_id: &str,
+ c: &Control,
+ cx: &mut Context<Self>,
+ ) -> gpui::Div {
+ match c {
+ Control::Toggle {
+ key,
+ label,
+ default,
+ hotkey,
+ } => {
+ let active = self
+ .toggle_state
+ .get(&(module_id.to_string(), key.clone()))
+ .copied()
+ .unwrap_or(*default);
+ let dot_color = if active {
+ theme.accent
+ } else {
+ theme.fg_disabled
+ };
+ let id_str: SharedString =
+ SharedString::from(format!("tts-toggle-{}-{}", module_id, key));
+ let id_for_listener = (module_id.to_string(), key.clone());
+ let row = div()
+ .id(gpui::ElementId::from(id_str))
+ .flex()
+ .flex_row()
+ .items_center()
+ .gap(px(8.0))
+ .px(px(6.0))
+ .py(px(3.0))
+ .rounded(px(4.0))
+ .hover(|s| s.bg(theme.bg_row_hover))
+ .child(div().size(px(8.0)).rounded(px(4.0)).bg(dot_color))
+ .child(
+ div()
+ .text_size(px(11.0))
+ .text_color(theme.fg_text)
+ .child(SharedString::from(label.clone())),
+ )
+ .child(
+ div()
+ .ml_auto()
+ .text_size(px(10.0))
+ .text_color(theme.fg_muted)
+ .child(SharedString::from(
+ hotkey
+ .clone()
+ .map(|h| format!("[{}]", h))
+ .unwrap_or_default(),
+ )),
+ )
+ .on_click(cx.listener(move |this, _: &ClickEvent, _, cx| {
+ let (m, k) = id_for_listener.clone();
+ this.on_toggle_click(m, k, cx);
+ }));
+ // `id()` devuelve `Stateful<Div>`; envolvemos para
+ // mantener uniforme el return type del match.
+ div().child(row)
+ }
+ Control::Slider {
+ label,
+ min,
+ max,
+ default,
+ ..
+ } => div()
.flex()
.flex_row()
.items_center()
.gap(px(8.0))
+ .px(px(6.0))
+ .py(px(3.0))
.child(
div()
- .size(px(8.0))
- .rounded(px(4.0))
- .bg(dot_color),
+ .text_size(px(11.0))
+ .text_color(theme.fg_text)
+ .child(SharedString::from(label.clone())),
)
+ .child(
+ div()
+ .ml_auto()
+ .text_size(px(10.0))
+ .text_color(theme.fg_muted)
+ .child(SharedString::from(format!("{} ({}…{})", default, min, max))),
+ ),
+ Control::Select { label, default, .. } => div()
+ .flex()
+ .flex_row()
+ .items_center()
+ .gap(px(8.0))
+ .px(px(6.0))
+ .py(px(3.0))
.child(
div()
.text_size(px(11.0))
@@ -187,69 +342,28 @@ fn render_control(theme: &Theme, c: &Control) -> gpui::Div {
.ml_auto()
.text_size(px(10.0))
.text_color(theme.fg_muted)
- .child(SharedString::from(
- hotkey.clone().map(|h| format!("[{}]", h)).unwrap_or_default(),
- )),
+ .child(SharedString::from(default.clone())),
+ ),
+ Control::TextInput { label, default, .. } => div()
+ .flex()
+ .flex_row()
+ .items_center()
+ .gap(px(8.0))
+ .px(px(6.0))
+ .py(px(3.0))
+ .child(
+ div()
+ .text_size(px(11.0))
+ .text_color(theme.fg_text)
+ .child(SharedString::from(label.clone())),
)
+ .child(
+ div()
+ .ml_auto()
+ .text_size(px(10.0))
+ .text_color(theme.fg_muted)
+ .child(SharedString::from(default.clone())),
+ ),
}
- Control::Slider {
- label, min, max, default, ..
- } => div()
- .flex()
- .flex_row()
- .items_center()
- .gap(px(8.0))
- .child(
- div()
- .text_size(px(11.0))
- .text_color(theme.fg_text)
- .child(SharedString::from(label.clone())),
- )
- .child(
- div()
- .ml_auto()
- .text_size(px(10.0))
- .text_color(theme.fg_muted)
- .child(SharedString::from(format!(
- "{} ({}…{})",
- default, min, max
- ))),
- ),
- Control::Select { label, default, .. } => div()
- .flex()
- .flex_row()
- .items_center()
- .gap(px(8.0))
- .child(
- div()
- .text_size(px(11.0))
- .text_color(theme.fg_text)
- .child(SharedString::from(label.clone())),
- )
- .child(
- div()
- .ml_auto()
- .text_size(px(10.0))
- .text_color(theme.fg_muted)
- .child(SharedString::from(default.clone())),
- ),
- Control::TextInput { label, default, .. } => div()
- .flex()
- .flex_row()
- .items_center()
- .gap(px(8.0))
- .child(
- div()
- .text_size(px(11.0))
- .text_color(theme.fg_text)
- .child(SharedString::from(label.clone())),
- )
- .child(
- div()
- .ml_auto()
- .text_size(px(10.0))
- .text_color(theme.fg_muted)
- .child(SharedString::from(default.clone())),
- ),
}
}