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, canvas: Entity, panel: Entity, + /// Carta abierta actualmente en el canvas. La cacheamos para poder + /// recomputarla con time-offsets sin re-leer la DB cada vez. + current_chart: Option, + 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 = 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 = charts .into_iter() @@ -170,18 +160,80 @@ impl Shell { } } - fn on_panel_event(&mut self, ev: &PanelEvent, cx: &mut Context) { - match ev { - PanelEvent::ModuleToggled { module_id, .. } => { - self.canvas - .update(cx, |c, cx| c.toggle_module(module_id, cx)); + fn render_current(&mut self, cx: &mut Context) { + 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; } - PanelEvent::ControlChanged { .. } => { - // Fase 4: aplicar config al canvas + persistir en store. + }; + 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) { + 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) { + match ev { + 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::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, } -#[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, + /// Por-LayerKind: `true` = visible. Default = todo visible. + pub layer_visibility: HashMap, + drag_jog: Option, +} + +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 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 { cx.observe_global::(|_, 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) { - 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.state.layer_visibility.insert(kind, visible); cx.notify(); } + pub fn toggle_layer(&mut self, kind: LayerKind, cx: &mut Context) { + 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) { + 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.state.view_rotation_deg = deg.rem_euclid(360.0); cx.notify(); } + + // ----- Internos: handlers de jog-dial ----- + + fn on_jog_down( + &mut self, + position: Point, + bounds: Bounds, + cx: &mut Context, + ) { + 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, + bounds: Bounds, + cx: &mut Context, + ) { + 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) { + 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) { + 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); + } +} + +// ===================================================================== +// Geometría de pantalla +// ===================================================================== + +const WHEEL_SIZE: f32 = 580.0; +const WHEEL_MARGIN: f32 = 28.0; + +fn bounds_center(bounds: Bounds) -> (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 // ===================================================================== -/// 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; - impl Render for AstrologyCanvas { fn render(&mut self, _w: &mut Window, cx: &mut Context) -> 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, + entity: gpui::Entity, ) -> 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 = 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, _w, _cx| (), move |bounds: Bounds, _, 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, + )); + } + } } } } - // --- Composición final con título arriba --- + // 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, + )); + } + } + } + } + + // --- 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, ) { - 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; + let (cx, cy) = bounds_center(bounds); + let _ = theme; + let show = |k: LayerKind| visibility.get(&k).copied().unwrap_or(true); - // 1. Sectores del zodíaco coloreados por elemento. - paint_sign_sectors(window, cx, cy, &radii, palette, ascendant_deg, rot_offset_deg); + // 1. Sectores zodiacales (parte del SignDial layer). + if show(LayerKind::SignDial) { + 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), - ); + // 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); - // 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 - }; + // 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, - lon, + ascendant_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, + ); + 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, ); } - // 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`; 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 { +pub fn compute_at_offset(chart: &Chart, offset_minutes: i64) -> Result { 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 { ) .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 { + 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 { #[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 { 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, + /// 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 for ControlPanel {} @@ -52,19 +59,77 @@ impl EventEmitter for ControlPanel {} impl ControlPanel { pub fn new(cx: &mut Context) -> Self { cx.observe_global::(|_, 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, cx: &mut Context) { + // 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.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) { + 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) -> 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)> = 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, + ) -> 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, + ) -> 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
`; 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())), - ), } }