//! `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, /// 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 {} impl ControlPanel { pub fn new(cx: &mut Context) -> Self { cx.observe_global::(|_, 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, 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(); // 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)) .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, ) -> 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, ) -> 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)) .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())), ), } } }