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:
@@ -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())),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user