360797132e
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>
370 lines
12 KiB
Rust
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())),
|
|
),
|
|
}
|
|
}
|
|
}
|