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
@@ -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,129 @@ 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())),
);
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())),
);
let mut controls = div().flex().flex_col().gap(px(4.0));
for c in m.controls() {
controls = controls.child(render_control(theme, &c));
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)
}
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)
}
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))
.child(
div()
.size(px(8.0))
.rounded(px(4.0))
.bg(dot_color),
)
.px(px(6.0))
.py(px(3.0))
.child(
div()
.text_size(px(11.0))
@@ -187,69 +322,48 @@ 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(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))
.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))
.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())),
),
}
}