Files
brahman/crates/modules/tahuantinsuyu/tahuantinsuyu-panel/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

370 lines
12 KiB
Rust

//! `tahuantinsuyu-panel` — control panel inferior de la app.
//!
//! 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 visibilidad sobre
//! el canvas (fase 4) y eventualmente a `ModuleState` en la store.
//!
//! ## 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::{
ClickEvent, Context, EventEmitter, IntoElement, ParentElement, Render, SharedString, Styled,
Window, div, prelude::*, px,
};
use tahuantinsuyu_model::ChartKind;
use tahuantinsuyu_modules::{Control, Module, Registry};
use yahweh_theme::Theme;
// =====================================================================
// Eventos
// =====================================================================
#[derive(Clone, Debug)]
pub enum PanelEvent {
ModuleToggled { module_id: String, enabled: bool },
ControlChanged {
module_id: String,
key: String,
value: serde_json::Value,
},
}
// =====================================================================
// Widget
// =====================================================================
pub struct ControlPanel {
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 {}
impl ControlPanel {
pub fn new(cx: &mut Context<Self>) -> Self {
cx.observe_global::<Theme>(|_, cx| cx.notify()).detach();
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();
// 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))
.px(px(12.0))
.flex()
.flex_row()
.items_center()
.gap(px(8.0))
.border_b_1()
.border_color(theme.border)
.child(
div()
.text_size(px(11.0))
.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"),
}),
);
let mut body = div()
.flex()
.flex_row()
.flex_wrap()
.gap(px(16.0))
.px(px(12.0))
.py(px(8.0));
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()
.size_full()
.bg(theme.bg_panel.clone())
.flex()
.flex_col()
.child(header)
.child(body)
}
}
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 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(
&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()
.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))
.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())),
),
}
}
}