tawasuyu

tawasuyu/brahman

Rust 326TOML 123JSON 14Markdown 114554 Definiciones

⎇ e0c5c02b8e2345247740805fdbb1fb9547ccb6b2 crates/modules/tahuantinsuyu/tahuantinsuyu-panel/src/lib.rs Rust · 595 líneas · 19.0 KB Historia Quién tocó qué crudo

feat(tahuantinsuyu): fase 8 — slider interactivo + slider de edad en progression e0c5c02b8 · sergio ·

1//! `tahuantinsuyu-panel` — control panel inferior de la app.
2//!
3//! Lee los módulos disponibles para la carta activa (vía
4//! [`tahuantinsuyu_modules::Registry::for_kind`]) y pinta sus
5//! [`Control`]s como toggles / sliders / selects. Cada cambio emite
6//! [`PanelEvent`] que la app traduce a mutaciones de visibilidad sobre
7//! el canvas y al `module_configs` del shell.
8//!
9//! ## Estado interno
10//!
11//! - `toggle_state: HashMap<(module_id, key), bool>` — valor actual de
12//!   cada toggle. Se inicializa lazy desde los defaults del módulo al
13//!   cambiar de `ChartKind`.
14//! - `slider_state: HashMap<(module_id, key), f64>` — valor actual de
15//!   cada slider. El shell puede sobreescribirlo via [`Self::set_slider`]
16//!   cuando cambia la carta activa (ej. inicializar `target_age_years`
17//!   con la edad actual del sujeto).
18//! - `slider_drag: Option<SliderDrag>` — slider que está bajo drag
19//!   activo. Mutuamente excluyente: solo se arrastra un slider a la vez.
20
21#![forbid(unsafe_code)]
22#![warn(rust_2018_idioms)]
23
24use std::collections::HashMap;
25
26use gpui::{
27    Bounds, ClickEvent, Context, EventEmitter, IntoElement, MouseButton, MouseDownEvent,
28    MouseMoveEvent, MouseUpEvent, ParentElement, Pixels, Point, Render, SharedString, Styled,
29    Window, canvas, div, prelude::*, px,
30};
31
32use tahuantinsuyu_model::ChartKind;
33use tahuantinsuyu_modules::{Control, Module, Registry};
34use yahweh_theme::Theme;
35
36// =====================================================================
37// Eventos
38// =====================================================================
39
40#[derive(Clone, Debug)]
41pub enum PanelEvent {
42    ModuleToggled { module_id: String, enabled: bool },
43    ControlChanged {
44        module_id: String,
45        key: String,
46        value: serde_json::Value,
47    },
48}
49
50// =====================================================================
51// Estado interno
52// =====================================================================
53
54#[derive(Clone, Debug)]
55struct SliderDrag {
56    module_id: String,
57    key: String,
58    min: f64,
59    max: f64,
60}
61
62// =====================================================================
63// Widget
64// =====================================================================
65
66pub struct ControlPanel {
67    active_kind: Option<ChartKind>,
68    toggle_state: HashMap<(String, String), bool>,
69    slider_state: HashMap<(String, String), f64>,
70    slider_drag: Option<SliderDrag>,
71    registry: Registry,
72}
73
74impl EventEmitter<PanelEvent> for ControlPanel {}
75
76impl ControlPanel {
77    pub fn new(cx: &mut Context<Self>) -> Self {
78        cx.observe_global::<Theme>(|_, cx| cx.notify()).detach();
79        Self {
80            active_kind: None,
81            toggle_state: HashMap::new(),
82            slider_state: HashMap::new(),
83            slider_drag: None,
84            registry: Registry::with_builtins(),
85        }
86    }
87
88    pub fn set_active_kind(&mut self, kind: Option<ChartKind>, cx: &mut Context<Self>) {
89        if self.active_kind != kind {
90            if let Some(k) = kind {
91                for m in self.registry.for_kind(k) {
92                    for c in m.controls() {
93                        match c {
94                            Control::Toggle { key, default, .. } => {
95                                self.toggle_state
96                                    .entry((m.id().to_string(), key))
97                                    .or_insert(default);
98                            }
99                            Control::Slider { key, default, .. } => {
100                                self.slider_state
101                                    .entry((m.id().to_string(), key))
102                                    .or_insert(default);
103                            }
104                            _ => {}
105                        }
106                    }
107                }
108            }
109        }
110        self.active_kind = kind;
111        cx.notify();
112    }
113
114    /// Setea un toggle desde afuera (sin emitir evento). Usado por el
115    /// shell para sincronizar cuando el canvas se autotoggleó via hotkey.
116    pub fn set_toggle(&mut self, module_id: &str, key: &str, value: bool, cx: &mut Context<Self>) {
117        self.toggle_state
118            .insert((module_id.to_string(), key.to_string()), value);
119        cx.notify();
120    }
121
122    /// Setea un slider desde afuera (sin emitir evento). El shell la
123    /// usa, por ejemplo, para inicializar `progression.target_age_years`
124    /// con la edad actual del sujeto al cargar una carta nueva.
125    pub fn set_slider(&mut self, module_id: &str, key: &str, value: f64, cx: &mut Context<Self>) {
126        self.slider_state
127            .insert((module_id.to_string(), key.to_string()), value);
128        cx.notify();
129    }
130
131    // ----- internos: handlers -----
132
133    fn on_toggle_click(&mut self, module_id: String, key: String, cx: &mut Context<Self>) {
134        let entry = self
135            .toggle_state
136            .entry((module_id.clone(), key.clone()))
137            .or_insert(true);
138        *entry = !*entry;
139        let new_val = *entry;
140        cx.emit(PanelEvent::ControlChanged {
141            module_id,
142            key,
143            value: serde_json::Value::Bool(new_val),
144        });
145        cx.notify();
146    }
147
148    fn start_slider_drag(
149        &mut self,
150        module_id: String,
151        key: String,
152        min: f64,
153        max: f64,
154        bounds: Bounds<Pixels>,
155        position: Point<Pixels>,
156        cx: &mut Context<Self>,
157    ) {
158        self.slider_drag = Some(SliderDrag {
159            module_id: module_id.clone(),
160            key: key.clone(),
161            min,
162            max,
163        });
164        self.apply_slider_position(bounds, position, cx);
165    }
166
167    fn continue_slider_drag(
168        &mut self,
169        bounds: Bounds<Pixels>,
170        position: Point<Pixels>,
171        cx: &mut Context<Self>,
172    ) {
173        if self.slider_drag.is_some() {
174            self.apply_slider_position(bounds, position, cx);
175        }
176    }
177
178    fn end_slider_drag(&mut self, cx: &mut Context<Self>) {
179        if self.slider_drag.take().is_some() {
180            cx.notify();
181        }
182    }
183
184    fn apply_slider_position(
185        &mut self,
186        bounds: Bounds<Pixels>,
187        position: Point<Pixels>,
188        cx: &mut Context<Self>,
189    ) {
190        let Some(drag) = self.slider_drag.as_ref().cloned() else {
191            return;
192        };
193        let track_x: f32 = bounds.origin.x.into();
194        let track_w: f32 = bounds.size.width.into();
195        let mouse_x: f32 = position.x.into();
196        let fraction = if track_w > 0.0 {
197            ((mouse_x - track_x) / track_w).clamp(0.0, 1.0) as f64
198        } else {
199            0.0
200        };
201        let value = drag.min + fraction * (drag.max - drag.min);
202        self.slider_state
203            .insert((drag.module_id.clone(), drag.key.clone()), value);
204        cx.emit(PanelEvent::ControlChanged {
205            module_id: drag.module_id,
206            key: drag.key,
207            value: serde_json::json!(value),
208        });
209        cx.notify();
210    }
211}
212
213// =====================================================================
214// Render
215// =====================================================================
216
217const SLIDER_TRACK_W: f32 = 140.0;
218const SLIDER_TRACK_H: f32 = 8.0;
219const SLIDER_THUMB: f32 = 12.0;
220
221impl Render for ControlPanel {
222    fn render(&mut self, _w: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
223        let theme = Theme::global(cx).clone();
224        let modules: Vec<(String, String, String, Vec<Control>)> = match self.active_kind {
225            Some(k) => self
226                .registry
227                .for_kind(k)
228                .iter()
229                .map(|m| {
230                    (
231                        m.id().to_string(),
232                        m.label().to_string(),
233                        m.description().to_string(),
234                        m.controls(),
235                    )
236                })
237                .collect(),
238            None => Vec::new(),
239        };
240
241        let header = div()
242            .h(px(28.0))
243            .px(px(12.0))
244            .flex()
245            .flex_row()
246            .items_center()
247            .gap(px(8.0))
248            .border_b_1()
249            .border_color(theme.border)
250            .child(
251                div()
252                    .text_size(px(11.0))
253                    .text_color(theme.fg_muted)
254                    .child("Panel de control"),
255            )
256            .child(
257                div()
258                    .ml_auto()
259                    .text_size(px(10.0))
260                    .text_color(theme.fg_disabled)
261                    .child(match self.active_kind {
262                        Some(k) => SharedString::from(format!("{:?}", k)),
263                        None => SharedString::from("sin carta activa"),
264                    }),
265            );
266
267        let mut body = div()
268            .flex()
269            .flex_row()
270            .flex_wrap()
271            .gap(px(16.0))
272            .px(px(12.0))
273            .py(px(8.0));
274
275        if modules.is_empty() {
276            body = body.child(
277                div()
278                    .text_size(px(11.0))
279                    .text_color(theme.fg_disabled)
280                    .child("Seleccioná una carta para ver sus controles."),
281            );
282        } else {
283            for (id, label, desc, controls) in &modules {
284                body = body.child(self.render_module(&theme, id, label, desc, controls, cx));
285            }
286        }
287
288        div()
289            .size_full()
290            .bg(theme.bg_panel.clone())
291            .flex()
292            .flex_col()
293            .child(header)
294            .child(body)
295    }
296}
297
298impl ControlPanel {
299    fn render_module(
300        &self,
301        theme: &Theme,
302        module_id: &str,
303        label: &str,
304        description: &str,
305        controls: &[Control],
306        cx: &mut Context<Self>,
307    ) -> gpui::Div {
308        let header = div()
309            .flex()
310            .flex_col()
311            .gap(px(2.0))
312            .child(
313                div()
314                    .text_size(px(12.0))
315                    .text_color(theme.fg_text)
316                    .child(SharedString::from(label.to_string())),
317            )
318            .child(
319                div()
320                    .text_size(px(10.0))
321                    .text_color(theme.fg_muted)
322                    .child(SharedString::from(description.to_string())),
323            );
324
325        let mut body = div().flex().flex_col().gap(px(4.0));
326        for c in controls {
327            body = body.child(self.render_control(theme, module_id, c, cx));
328        }
329
330        div()
331            .min_w(px(260.0))
332            .p(px(8.0))
333            .rounded(px(6.0))
334            .bg(theme.bg_panel_alt.clone())
335            .border_1()
336            .border_color(theme.border)
337            .flex()
338            .flex_col()
339            .gap(px(6.0))
340            .child(header)
341            .child(body)
342    }
343
344    fn render_control(
345        &self,
346        theme: &Theme,
347        module_id: &str,
348        c: &Control,
349        cx: &mut Context<Self>,
350    ) -> gpui::Div {
351        match c {
352            Control::Toggle {
353                key,
354                label,
355                default,
356                hotkey,
357            } => self.render_toggle(theme, module_id, key, label, *default, hotkey.as_deref(), cx),
358            Control::Slider {
359                key,
360                label,
361                min,
362                max,
363                default,
364                ..
365            } => self.render_slider(theme, module_id, key, label, *min, *max, *default, cx),
366            Control::Select { label, default, .. } => display_row(theme, label, default),
367            Control::TextInput { label, default, .. } => display_row(theme, label, default),
368        }
369    }
370
371    fn render_toggle(
372        &self,
373        theme: &Theme,
374        module_id: &str,
375        key: &str,
376        label: &str,
377        default: bool,
378        hotkey: Option<&str>,
379        cx: &mut Context<Self>,
380    ) -> gpui::Div {
381        let active = self
382            .toggle_state
383            .get(&(module_id.to_string(), key.to_string()))
384            .copied()
385            .unwrap_or(default);
386        let dot_color = if active {
387            theme.accent
388        } else {
389            theme.fg_disabled
390        };
391        let id_str: SharedString =
392            SharedString::from(format!("tts-toggle-{}-{}", module_id, key));
393        let id_for_listener = (module_id.to_string(), key.to_string());
394        let hotkey_str = hotkey
395            .map(|h| format!("[{}]", h))
396            .unwrap_or_default();
397        let row = div()
398            .id(gpui::ElementId::from(id_str))
399            .flex()
400            .flex_row()
401            .items_center()
402            .gap(px(8.0))
403            .px(px(6.0))
404            .py(px(3.0))
405            .rounded(px(4.0))
406            .hover(|s| s.bg(theme.bg_row_hover))
407            .child(div().size(px(8.0)).rounded(px(4.0)).bg(dot_color))
408            .child(
409                div()
410                    .text_size(px(11.0))
411                    .text_color(theme.fg_text)
412                    .child(SharedString::from(label.to_string())),
413            )
414            .child(
415                div()
416                    .ml_auto()
417                    .text_size(px(10.0))
418                    .text_color(theme.fg_muted)
419                    .child(SharedString::from(hotkey_str)),
420            )
421            .on_click(cx.listener(move |this, _: &ClickEvent, _, cx| {
422                let (m, k) = id_for_listener.clone();
423                this.on_toggle_click(m, k, cx);
424            }));
425        div().child(row)
426    }
427
428    fn render_slider(
429        &self,
430        theme: &Theme,
431        module_id: &str,
432        key: &str,
433        label: &str,
434        min: f64,
435        max: f64,
436        default: f64,
437        cx: &mut Context<Self>,
438    ) -> gpui::Div {
439        let value = self
440            .slider_state
441            .get(&(module_id.to_string(), key.to_string()))
442            .copied()
443            .unwrap_or(default);
444        let range = (max - min).max(f64::EPSILON);
445        let fraction = ((value - min) / range).clamp(0.0, 1.0) as f32;
446        let filled_w = fraction * SLIDER_TRACK_W;
447        let thumb_x = (fraction * SLIDER_TRACK_W) - SLIDER_THUMB / 2.0;
448
449        let entity = cx.entity();
450        let mod_for_mouse = module_id.to_string();
451        let key_for_mouse = key.to_string();
452        let canvas_overlay = canvas(
453            move |_bounds: Bounds<Pixels>, _w, _cx| (),
454            move |bounds: Bounds<Pixels>, _, window, _| {
455                // MouseDown sobre el track → start drag + valor inmediato.
456                let entity_d = entity.clone();
457                let mod_d = mod_for_mouse.clone();
458                let key_d = key_for_mouse.clone();
459                window.on_mouse_event(move |ev: &MouseDownEvent, _, _w, cx| {
460                    if ev.button != MouseButton::Left {
461                        return;
462                    }
463                    if !bounds.contains(&ev.position) {
464                        return;
465                    }
466                    entity_d.update(cx, |this, cx| {
467                        this.start_slider_drag(
468                            mod_d.clone(),
469                            key_d.clone(),
470                            min,
471                            max,
472                            bounds,
473                            ev.position,
474                            cx,
475                        );
476                    });
477                });
478
479                // MouseMove (durante drag) → continuar solo si ESTE
480                // slider es el que está bajo drag.
481                let entity_m = entity.clone();
482                let mod_m = mod_for_mouse.clone();
483                let key_m = key_for_mouse.clone();
484                window.on_mouse_event(move |ev: &MouseMoveEvent, _, _w, cx| {
485                    if !ev.dragging() {
486                        return;
487                    }
488                    entity_m.update(cx, |this, cx| {
489                        let is_mine = this
490                            .slider_drag
491                            .as_ref()
492                            .map(|d| d.module_id == mod_m && d.key == key_m)
493                            .unwrap_or(false);
494                        if is_mine {
495                            this.continue_slider_drag(bounds, ev.position, cx);
496                        }
497                    });
498                });
499
500                // MouseUp anywhere → terminar drag.
501                let entity_u = entity.clone();
502                window.on_mouse_event(move |_: &MouseUpEvent, _, _w, cx| {
503                    entity_u.update(cx, |this, cx| this.end_slider_drag(cx));
504                });
505            },
506        )
507        .absolute()
508        .w(px(SLIDER_TRACK_W))
509        .h(px(SLIDER_TRACK_H));
510
511        let track = div()
512            .relative()
513            .w(px(SLIDER_TRACK_W))
514            .h(px(SLIDER_TRACK_H))
515            .bg(theme.bg_input())
516            .rounded(px(SLIDER_TRACK_H / 2.0))
517            .child(
518                div()
519                    .absolute()
520                    .left(px(0.0))
521                    .top(px(0.0))
522                    .h(px(SLIDER_TRACK_H))
523                    .w(px(filled_w))
524                    .bg(theme.accent)
525                    .rounded(px(SLIDER_TRACK_H / 2.0)),
526            )
527            .child(
528                div()
529                    .absolute()
530                    .left(px(thumb_x))
531                    .top(px(-2.0))
532                    .w(px(SLIDER_THUMB))
533                    .h(px(SLIDER_THUMB))
534                    .rounded(px(SLIDER_THUMB / 2.0))
535                    .bg(theme.fg_text)
536                    .border_1()
537                    .border_color(theme.border_strong),
538            )
539            .child(canvas_overlay);
540
541        div()
542            .flex()
543            .flex_col()
544            .gap(px(4.0))
545            .px(px(6.0))
546            .py(px(3.0))
547            .child(
548                div()
549                    .flex()
550                    .flex_row()
551                    .items_center()
552                    .gap(px(8.0))
553                    .child(
554                        div()
555                            .text_size(px(11.0))
556                            .text_color(theme.fg_text)
557                            .child(SharedString::from(label.to_string())),
558                    )
559                    .child(
560                        div()
561                            .ml_auto()
562                            .text_size(px(10.0))
563                            .text_color(theme.fg_muted)
564                            .child(SharedString::from(format!(
565                                "{:.1}  ({}…{})",
566                                value, min, max
567                            ))),
568                    ),
569            )
570            .child(track)
571    }
572}
573
574fn display_row(theme: &Theme, label: &str, value: &str) -> gpui::Div {
575    div()
576        .flex()
577        .flex_row()
578        .items_center()
579        .gap(px(8.0))
580        .px(px(6.0))
581        .py(px(3.0))
582        .child(
583            div()
584                .text_size(px(11.0))
585                .text_color(theme.fg_text)
586                .child(SharedString::from(label.to_string())),
587        )
588        .child(
589            div()
590                .ml_auto()
591                .text_size(px(10.0))
592                .text_color(theme.fg_muted)
593                .child(SharedString::from(value.to_string())),
594        )
595}