tawasuyu

tawasuyu/brahman

Rust 326TOML 123JSON 14Markdown 114596 Sutichaykuna

⎇ d3649bfd1a80153652c2a028315369bb4b184024 crates/modules/tahuantinsuyu/tahuantinsuyu-panel/src/lib.rs Rust · 981 siq'ikuna · 32.2 KB Ñawpa willay Pi imata llamkarqan ch'uya

feat(tahuantinsuyu): fase 20 — accordion + lunar shift + CompositeModule + 90 ciudades d3649bfd1 · 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, SelectOption};
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/// Opción que el host inyecta al panel para que los `Control::ChartPicker`
51/// puedan mostrar el dropdown. El `id` es el ULID stringificado de la
52/// carta; el `label` es lo que se muestra en el dropdown.
53#[derive(Clone, Debug)]
54pub struct ChartOption {
55    pub id: String,
56    pub label: String,
57}
58
59// =====================================================================
60// Estado interno
61// =====================================================================
62
63#[derive(Clone, Debug)]
64struct SliderDrag {
65    module_id: String,
66    key: String,
67    min: f64,
68    max: f64,
69}
70
71// =====================================================================
72// Widget
73// =====================================================================
74
75pub struct ControlPanel {
76    active_kind: Option<ChartKind>,
77    toggle_state: HashMap<(String, String), bool>,
78    slider_state: HashMap<(String, String), f64>,
79    slider_drag: Option<SliderDrag>,
80    /// Opciones globales para todos los `ChartPicker` — las inyecta el
81    /// shell vía [`Self::set_chart_options`]. Compartido entre todos
82    /// los pickers porque típicamente representan "todas las cartas
83    /// del DB" sin filtros por módulo.
84    chart_options: Vec<ChartOption>,
85    /// Valor actual de cualquier control basado en string (ChartPicker
86    /// y Select comparten storage). `None` = sin selección — el render
87    /// muestra placeholder ("automático" en picker, default-label en
88    /// select).
89    string_state: HashMap<(String, String), Option<String>>,
90    /// Si hay un dropdown abierto, su (module_id, key). Mutuamente
91    /// excluyente: solo uno abierto a la vez en todo el panel.
92    dropdown_open: Option<(String, String)>,
93    /// Overrides explícitos del estado expanded/collapsed por módulo.
94    /// La semántica del default (sin override) está en
95    /// [`Self::is_collapsed`]: natal y módulos enabled = expanded;
96    /// el resto collapsed.
97    collapse_overrides: HashMap<String, bool>,
98    registry: Registry,
99}
100
101impl EventEmitter<PanelEvent> for ControlPanel {}
102
103impl ControlPanel {
104    pub fn new(cx: &mut Context<Self>) -> Self {
105        cx.observe_global::<Theme>(|_, cx| cx.notify()).detach();
106        Self {
107            active_kind: None,
108            toggle_state: HashMap::new(),
109            slider_state: HashMap::new(),
110            slider_drag: None,
111            chart_options: Vec::new(),
112            string_state: HashMap::new(),
113            dropdown_open: None,
114            collapse_overrides: HashMap::new(),
115            registry: Registry::with_builtins(),
116        }
117    }
118
119    /// Decide si el card de un módulo debe pintarse collapsed (solo
120    /// header) o expanded (header + controles). La regla: si el usuario
121    /// puso un override explícito lo respetamos; sino, natal va
122    /// expanded siempre y el resto solo si su toggle "enabled" es true.
123    fn is_collapsed(&self, module_id: &str) -> bool {
124        if let Some(v) = self.collapse_overrides.get(module_id) {
125            return *v;
126        }
127        if module_id == "natal" {
128            return false;
129        }
130        !self
131            .toggle_state
132            .get(&(module_id.to_string(), "enabled".to_string()))
133            .copied()
134            .unwrap_or(false)
135    }
136
137    fn toggle_collapsed(&mut self, module_id: String, cx: &mut Context<Self>) {
138        let current = self.is_collapsed(&module_id);
139        self.collapse_overrides.insert(module_id, !current);
140        cx.notify();
141    }
142
143    pub fn set_active_kind(&mut self, kind: Option<ChartKind>, cx: &mut Context<Self>) {
144        if self.active_kind != kind {
145            if let Some(k) = kind {
146                for m in self.registry.for_kind(k) {
147                    for c in m.controls() {
148                        match c {
149                            Control::Toggle { key, default, .. } => {
150                                self.toggle_state
151                                    .entry((m.id().to_string(), key))
152                                    .or_insert(default);
153                            }
154                            Control::Slider { key, default, .. } => {
155                                self.slider_state
156                                    .entry((m.id().to_string(), key))
157                                    .or_insert(default);
158                            }
159                            Control::ChartPicker { key, .. } => {
160                                self.string_state
161                                    .entry((m.id().to_string(), key))
162                                    .or_insert(None);
163                            }
164                            Control::Select { key, default, .. } => {
165                                self.string_state
166                                    .entry((m.id().to_string(), key))
167                                    .or_insert(Some(default));
168                            }
169                            _ => {}
170                        }
171                    }
172                }
173            }
174        }
175        self.active_kind = kind;
176        // Cerrar cualquier dropdown abierto al cambiar de carta.
177        self.dropdown_open = None;
178        cx.notify();
179    }
180
181    /// Setea un toggle desde afuera (sin emitir evento). Usado por el
182    /// shell para sincronizar cuando el canvas se autotoggleó via hotkey.
183    pub fn set_toggle(&mut self, module_id: &str, key: &str, value: bool, cx: &mut Context<Self>) {
184        self.toggle_state
185            .insert((module_id.to_string(), key.to_string()), value);
186        cx.notify();
187    }
188
189    /// Setea un slider desde afuera (sin emitir evento). El shell la
190    /// usa, por ejemplo, para inicializar `progression.target_age_years`
191    /// con la edad actual del sujeto al cargar una carta nueva.
192    pub fn set_slider(&mut self, module_id: &str, key: &str, value: f64, cx: &mut Context<Self>) {
193        self.slider_state
194            .insert((module_id.to_string(), key.to_string()), value);
195        cx.notify();
196    }
197
198    /// Reemplaza el catálogo de opciones que muestran los
199    /// `Control::ChartPicker`. El shell la llama cada vez que la
200    /// jerarquía de cartas cambia (crear/borrar) para que el dropdown
201    /// quede al día sin necesidad de re-instanciar el panel.
202    pub fn set_chart_options(&mut self, options: Vec<ChartOption>, cx: &mut Context<Self>) {
203        self.chart_options = options;
204        cx.notify();
205    }
206
207    /// Setea el valor de un control basado en string (ChartPicker o
208    /// Select) desde afuera, sin emitir. El shell la usa para restaurar
209    /// el valor persistido al cargar una carta.
210    pub fn set_string(
211        &mut self,
212        module_id: &str,
213        key: &str,
214        value: Option<String>,
215        cx: &mut Context<Self>,
216    ) {
217        self.string_state
218            .insert((module_id.to_string(), key.to_string()), value);
219        cx.notify();
220    }
221
222    /// Alias retrocompatible — los call-sites antiguos del shell usaban
223    /// `set_chart_picker`. Funcionalmente idéntico a [`Self::set_string`].
224    pub fn set_chart_picker(
225        &mut self,
226        module_id: &str,
227        key: &str,
228        chart_id: Option<String>,
229        cx: &mut Context<Self>,
230    ) {
231        self.set_string(module_id, key, chart_id, cx);
232    }
233
234    // ----- internos: handlers -----
235
236    fn on_toggle_click(&mut self, module_id: String, key: String, cx: &mut Context<Self>) {
237        let entry = self
238            .toggle_state
239            .entry((module_id.clone(), key.clone()))
240            .or_insert(true);
241        *entry = !*entry;
242        let new_val = *entry;
243        cx.emit(PanelEvent::ControlChanged {
244            module_id,
245            key,
246            value: serde_json::Value::Bool(new_val),
247        });
248        cx.notify();
249    }
250
251    fn start_slider_drag(
252        &mut self,
253        module_id: String,
254        key: String,
255        min: f64,
256        max: f64,
257        bounds: Bounds<Pixels>,
258        position: Point<Pixels>,
259        cx: &mut Context<Self>,
260    ) {
261        self.slider_drag = Some(SliderDrag {
262            module_id: module_id.clone(),
263            key: key.clone(),
264            min,
265            max,
266        });
267        self.apply_slider_position(bounds, position, cx);
268    }
269
270    fn continue_slider_drag(
271        &mut self,
272        bounds: Bounds<Pixels>,
273        position: Point<Pixels>,
274        cx: &mut Context<Self>,
275    ) {
276        if self.slider_drag.is_some() {
277            self.apply_slider_position(bounds, position, cx);
278        }
279    }
280
281    fn end_slider_drag(&mut self, cx: &mut Context<Self>) {
282        if self.slider_drag.take().is_some() {
283            cx.notify();
284        }
285    }
286
287    fn toggle_dropdown_open(&mut self, module_id: String, key: String, cx: &mut Context<Self>) {
288        let key_pair = (module_id, key);
289        let new_state = match self.dropdown_open.as_ref() {
290            Some(open) if open == &key_pair => None,
291            _ => Some(key_pair),
292        };
293        self.dropdown_open = new_state;
294        cx.notify();
295    }
296
297    fn select_string_value(
298        &mut self,
299        module_id: String,
300        key: String,
301        value: Option<String>,
302        cx: &mut Context<Self>,
303    ) {
304        self.string_state
305            .insert((module_id.clone(), key.clone()), value.clone());
306        self.dropdown_open = None;
307        let json_value = match value {
308            Some(s) => serde_json::Value::String(s),
309            None => serde_json::Value::Null,
310        };
311        cx.emit(PanelEvent::ControlChanged {
312            module_id,
313            key,
314            value: json_value,
315        });
316        cx.notify();
317    }
318
319    fn apply_slider_position(
320        &mut self,
321        bounds: Bounds<Pixels>,
322        position: Point<Pixels>,
323        cx: &mut Context<Self>,
324    ) {
325        let Some(drag) = self.slider_drag.as_ref().cloned() else {
326            return;
327        };
328        let track_x: f32 = bounds.origin.x.into();
329        let track_w: f32 = bounds.size.width.into();
330        let mouse_x: f32 = position.x.into();
331        let fraction = if track_w > 0.0 {
332            ((mouse_x - track_x) / track_w).clamp(0.0, 1.0) as f64
333        } else {
334            0.0
335        };
336        let value = drag.min + fraction * (drag.max - drag.min);
337        self.slider_state
338            .insert((drag.module_id.clone(), drag.key.clone()), value);
339        cx.emit(PanelEvent::ControlChanged {
340            module_id: drag.module_id,
341            key: drag.key,
342            value: serde_json::json!(value),
343        });
344        cx.notify();
345    }
346}
347
348// =====================================================================
349// Render
350// =====================================================================
351
352const SLIDER_TRACK_W: f32 = 140.0;
353const SLIDER_TRACK_H: f32 = 8.0;
354const SLIDER_THUMB: f32 = 12.0;
355
356impl Render for ControlPanel {
357    fn render(&mut self, _w: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
358        let theme = Theme::global(cx).clone();
359        let modules: Vec<(String, String, String, Vec<Control>)> = match self.active_kind {
360            Some(k) => self
361                .registry
362                .for_kind(k)
363                .iter()
364                .map(|m| {
365                    (
366                        m.id().to_string(),
367                        m.label().to_string(),
368                        m.description().to_string(),
369                        m.controls(),
370                    )
371                })
372                .collect(),
373            None => Vec::new(),
374        };
375
376        let header = div()
377            .h(px(28.0))
378            .px(px(12.0))
379            .flex()
380            .flex_row()
381            .items_center()
382            .gap(px(8.0))
383            .border_b_1()
384            .border_color(theme.border)
385            .child(
386                div()
387                    .text_size(px(11.0))
388                    .text_color(theme.fg_muted)
389                    .child("Panel de control"),
390            )
391            .child(
392                div()
393                    .ml_auto()
394                    .text_size(px(10.0))
395                    .text_color(theme.fg_disabled)
396                    .child(match self.active_kind {
397                        Some(k) => SharedString::from(format!("{:?}", k)),
398                        None => SharedString::from("sin carta activa"),
399                    }),
400            );
401
402        let mut body = div()
403            .flex()
404            .flex_row()
405            .flex_wrap()
406            .gap(px(16.0))
407            .px(px(12.0))
408            .py(px(8.0));
409
410        if modules.is_empty() {
411            body = body.child(
412                div()
413                    .text_size(px(11.0))
414                    .text_color(theme.fg_disabled)
415                    .child("Seleccioná una carta para ver sus controles."),
416            );
417        } else {
418            for (id, label, desc, controls) in &modules {
419                body = body.child(self.render_module(&theme, id, label, desc, controls, cx));
420            }
421        }
422
423        div()
424            .size_full()
425            .bg(theme.bg_panel.clone())
426            .flex()
427            .flex_col()
428            .child(header)
429            .child(body)
430    }
431}
432
433impl ControlPanel {
434    fn render_module(
435        &self,
436        theme: &Theme,
437        module_id: &str,
438        label: &str,
439        description: &str,
440        controls: &[Control],
441        cx: &mut Context<Self>,
442    ) -> gpui::Div {
443        let collapsed = self.is_collapsed(module_id);
444        let chevron = if collapsed { "▸" } else { "▾" };
445        let header_id: SharedString =
446            SharedString::from(format!("tts-module-header-{}", module_id));
447        let module_id_for_listener = module_id.to_string();
448        let header = div()
449            .id(gpui::ElementId::from(header_id))
450            .flex()
451            .flex_row()
452            .items_center()
453            .gap(px(8.0))
454            .hover(|s| s.bg(theme.bg_row_hover))
455            .rounded(px(4.0))
456            .px(px(4.0))
457            .py(px(2.0))
458            .child(
459                div()
460                    .text_size(px(11.0))
461                    .text_color(theme.fg_muted)
462                    .child(chevron),
463            )
464            .child(
465                div()
466                    .flex()
467                    .flex_col()
468                    .flex_grow()
469                    .gap(px(2.0))
470                    .child(
471                        div()
472                            .text_size(px(12.0))
473                            .text_color(theme.fg_text)
474                            .child(SharedString::from(label.to_string())),
475                    )
476                    .child(
477                        div()
478                            .text_size(px(10.0))
479                            .text_color(theme.fg_muted)
480                            .child(SharedString::from(description.to_string())),
481                    ),
482            )
483            .on_click(cx.listener(move |this, _: &ClickEvent, _, cx| {
484                this.toggle_collapsed(module_id_for_listener.clone(), cx);
485            }));
486
487        let mut card = div()
488            .min_w(px(260.0))
489            .p(px(8.0))
490            .rounded(px(6.0))
491            .bg(theme.bg_panel_alt.clone())
492            .border_1()
493            .border_color(theme.border)
494            .flex()
495            .flex_col()
496            .gap(px(6.0))
497            .child(header);
498        if !collapsed {
499            let mut body = div().flex().flex_col().gap(px(4.0));
500            for c in controls {
501                body = body.child(self.render_control(theme, module_id, c, cx));
502            }
503            card = card.child(body);
504        }
505        card
506    }
507
508    fn render_control(
509        &self,
510        theme: &Theme,
511        module_id: &str,
512        c: &Control,
513        cx: &mut Context<Self>,
514    ) -> gpui::Div {
515        match c {
516            Control::Toggle {
517                key,
518                label,
519                default,
520                hotkey,
521            } => self.render_toggle(theme, module_id, key, label, *default, hotkey.as_deref(), cx),
522            Control::Slider {
523                key,
524                label,
525                min,
526                max,
527                default,
528                ..
529            } => self.render_slider(theme, module_id, key, label, *min, *max, *default, cx),
530            Control::ChartPicker { key, label } => {
531                self.render_chart_picker(theme, module_id, key, label, cx)
532            }
533            Control::Select {
534                key,
535                label,
536                options,
537                default,
538            } => self.render_select(theme, module_id, key, label, options, default, cx),
539            Control::TextInput { label, default, .. } => display_row(theme, label, default),
540        }
541    }
542
543    fn render_toggle(
544        &self,
545        theme: &Theme,
546        module_id: &str,
547        key: &str,
548        label: &str,
549        default: bool,
550        hotkey: Option<&str>,
551        cx: &mut Context<Self>,
552    ) -> gpui::Div {
553        let active = self
554            .toggle_state
555            .get(&(module_id.to_string(), key.to_string()))
556            .copied()
557            .unwrap_or(default);
558        let dot_color = if active {
559            theme.accent
560        } else {
561            theme.fg_disabled
562        };
563        let id_str: SharedString =
564            SharedString::from(format!("tts-toggle-{}-{}", module_id, key));
565        let id_for_listener = (module_id.to_string(), key.to_string());
566        let hotkey_str = hotkey
567            .map(|h| format!("[{}]", h))
568            .unwrap_or_default();
569        let row = div()
570            .id(gpui::ElementId::from(id_str))
571            .flex()
572            .flex_row()
573            .items_center()
574            .gap(px(8.0))
575            .px(px(6.0))
576            .py(px(3.0))
577            .rounded(px(4.0))
578            .hover(|s| s.bg(theme.bg_row_hover))
579            .child(div().size(px(8.0)).rounded(px(4.0)).bg(dot_color))
580            .child(
581                div()
582                    .text_size(px(11.0))
583                    .text_color(theme.fg_text)
584                    .child(SharedString::from(label.to_string())),
585            )
586            .child(
587                div()
588                    .ml_auto()
589                    .text_size(px(10.0))
590                    .text_color(theme.fg_muted)
591                    .child(SharedString::from(hotkey_str)),
592            )
593            .on_click(cx.listener(move |this, _: &ClickEvent, _, cx| {
594                let (m, k) = id_for_listener.clone();
595                this.on_toggle_click(m, k, cx);
596            }));
597        div().child(row)
598    }
599
600    fn render_slider(
601        &self,
602        theme: &Theme,
603        module_id: &str,
604        key: &str,
605        label: &str,
606        min: f64,
607        max: f64,
608        default: f64,
609        cx: &mut Context<Self>,
610    ) -> gpui::Div {
611        let value = self
612            .slider_state
613            .get(&(module_id.to_string(), key.to_string()))
614            .copied()
615            .unwrap_or(default);
616        let range = (max - min).max(f64::EPSILON);
617        let fraction = ((value - min) / range).clamp(0.0, 1.0) as f32;
618        let filled_w = fraction * SLIDER_TRACK_W;
619        let thumb_x = (fraction * SLIDER_TRACK_W) - SLIDER_THUMB / 2.0;
620
621        let entity = cx.entity();
622        let mod_for_mouse = module_id.to_string();
623        let key_for_mouse = key.to_string();
624        let canvas_overlay = canvas(
625            move |_bounds: Bounds<Pixels>, _w, _cx| (),
626            move |bounds: Bounds<Pixels>, _, window, _| {
627                // MouseDown sobre el track → start drag + valor inmediato.
628                let entity_d = entity.clone();
629                let mod_d = mod_for_mouse.clone();
630                let key_d = key_for_mouse.clone();
631                window.on_mouse_event(move |ev: &MouseDownEvent, _, _w, cx| {
632                    if ev.button != MouseButton::Left {
633                        return;
634                    }
635                    if !bounds.contains(&ev.position) {
636                        return;
637                    }
638                    entity_d.update(cx, |this, cx| {
639                        this.start_slider_drag(
640                            mod_d.clone(),
641                            key_d.clone(),
642                            min,
643                            max,
644                            bounds,
645                            ev.position,
646                            cx,
647                        );
648                    });
649                });
650
651                // MouseMove (durante drag) → continuar solo si ESTE
652                // slider es el que está bajo drag.
653                let entity_m = entity.clone();
654                let mod_m = mod_for_mouse.clone();
655                let key_m = key_for_mouse.clone();
656                window.on_mouse_event(move |ev: &MouseMoveEvent, _, _w, cx| {
657                    if !ev.dragging() {
658                        return;
659                    }
660                    entity_m.update(cx, |this, cx| {
661                        let is_mine = this
662                            .slider_drag
663                            .as_ref()
664                            .map(|d| d.module_id == mod_m && d.key == key_m)
665                            .unwrap_or(false);
666                        if is_mine {
667                            this.continue_slider_drag(bounds, ev.position, cx);
668                        }
669                    });
670                });
671
672                // MouseUp anywhere → terminar drag.
673                let entity_u = entity.clone();
674                window.on_mouse_event(move |_: &MouseUpEvent, _, _w, cx| {
675                    entity_u.update(cx, |this, cx| this.end_slider_drag(cx));
676                });
677            },
678        )
679        .absolute()
680        .w(px(SLIDER_TRACK_W))
681        .h(px(SLIDER_TRACK_H));
682
683        let track = div()
684            .relative()
685            .w(px(SLIDER_TRACK_W))
686            .h(px(SLIDER_TRACK_H))
687            .bg(theme.bg_input())
688            .rounded(px(SLIDER_TRACK_H / 2.0))
689            .child(
690                div()
691                    .absolute()
692                    .left(px(0.0))
693                    .top(px(0.0))
694                    .h(px(SLIDER_TRACK_H))
695                    .w(px(filled_w))
696                    .bg(theme.accent)
697                    .rounded(px(SLIDER_TRACK_H / 2.0)),
698            )
699            .child(
700                div()
701                    .absolute()
702                    .left(px(thumb_x))
703                    .top(px(-2.0))
704                    .w(px(SLIDER_THUMB))
705                    .h(px(SLIDER_THUMB))
706                    .rounded(px(SLIDER_THUMB / 2.0))
707                    .bg(theme.fg_text)
708                    .border_1()
709                    .border_color(theme.border_strong),
710            )
711            .child(canvas_overlay);
712
713        div()
714            .flex()
715            .flex_col()
716            .gap(px(4.0))
717            .px(px(6.0))
718            .py(px(3.0))
719            .child(
720                div()
721                    .flex()
722                    .flex_row()
723                    .items_center()
724                    .gap(px(8.0))
725                    .child(
726                        div()
727                            .text_size(px(11.0))
728                            .text_color(theme.fg_text)
729                            .child(SharedString::from(label.to_string())),
730                    )
731                    .child(
732                        div()
733                            .ml_auto()
734                            .text_size(px(10.0))
735                            .text_color(theme.fg_muted)
736                            .child(SharedString::from(format!(
737                                "{:.1}  ({}…{})",
738                                value, min, max
739                            ))),
740                    ),
741            )
742            .child(track)
743    }
744}
745
746impl ControlPanel {
747    fn render_chart_picker(
748        &self,
749        theme: &Theme,
750        module_id: &str,
751        key: &str,
752        label: &str,
753        cx: &mut Context<Self>,
754    ) -> gpui::Div {
755        let options: Vec<(String, String)> = self
756            .chart_options
757            .iter()
758            .map(|o| (o.id.clone(), o.label.clone()))
759            .collect();
760        self.render_dropdown(
761            theme,
762            module_id,
763            key,
764            label,
765            "(automático)",
766            &options,
767            true, // incluir opción "(automático)" en el popup
768            cx,
769        )
770    }
771
772    fn render_select(
773        &self,
774        theme: &Theme,
775        module_id: &str,
776        key: &str,
777        label: &str,
778        options: &[SelectOption],
779        default: &str,
780        cx: &mut Context<Self>,
781    ) -> gpui::Div {
782        let opts: Vec<(String, String)> = options
783            .iter()
784            .map(|o| (o.value.clone(), o.label.clone()))
785            .collect();
786        let placeholder = options
787            .iter()
788            .find(|o| o.value == default)
789            .map(|o| o.label.clone())
790            .unwrap_or_else(|| default.to_string());
791        self.render_dropdown(theme, module_id, key, label, &placeholder, &opts, false, cx)
792    }
793
794    #[allow(clippy::too_many_arguments)]
795    fn render_dropdown(
796        &self,
797        theme: &Theme,
798        module_id: &str,
799        key: &str,
800        label: &str,
801        placeholder: &str,
802        options: &[(String, String)],
803        include_auto: bool,
804        cx: &mut Context<Self>,
805    ) -> gpui::Div {
806        let current_value = self
807            .string_state
808            .get(&(module_id.to_string(), key.to_string()))
809            .cloned()
810            .flatten();
811        let current_label = current_value
812            .as_ref()
813            .and_then(|v| options.iter().find(|(val, _)| val == v).map(|(_, l)| l.clone()))
814            .unwrap_or_else(|| placeholder.to_string());
815
816        let is_open = self
817            .dropdown_open
818            .as_ref()
819            .map(|(m, k)| m == module_id && k == key)
820            .unwrap_or(false);
821
822        let module_id_btn = module_id.to_string();
823        let key_btn = key.to_string();
824        let btn_id: SharedString =
825            SharedString::from(format!("tts-dropdown-btn-{}-{}", module_id, key));
826        let button = div()
827            .id(gpui::ElementId::from(btn_id))
828            .px(px(10.0))
829            .py(px(5.0))
830            .rounded(px(4.0))
831            .bg(theme.bg_button())
832            .hover(|s| s.bg(theme.bg_button_hover()))
833            .border_1()
834            .border_color(if is_open {
835                theme.accent_strong
836            } else {
837                theme.border
838            })
839            .text_size(px(11.0))
840            .text_color(theme.fg_text)
841            .child(SharedString::from(format!("▾ {}", current_label)))
842            .on_click(cx.listener(move |this, _: &ClickEvent, _, cx| {
843                this.toggle_dropdown_open(module_id_btn.clone(), key_btn.clone(), cx);
844            }));
845
846        let mut wrapper = div()
847            .relative()
848            .flex()
849            .flex_col()
850            .gap(px(2.0))
851            .child(
852                div()
853                    .text_size(px(10.0))
854                    .text_color(theme.fg_muted)
855                    .child(SharedString::from(label.to_string())),
856            )
857            .child(button);
858
859        if is_open {
860            wrapper = wrapper.child(self.render_dropdown_popup(
861                theme,
862                module_id,
863                key,
864                options,
865                include_auto,
866                cx,
867            ));
868        }
869
870        div().px(px(6.0)).py(px(3.0)).child(wrapper)
871    }
872
873    fn render_dropdown_popup(
874        &self,
875        theme: &Theme,
876        module_id: &str,
877        key: &str,
878        options: &[(String, String)],
879        include_auto: bool,
880        cx: &mut Context<Self>,
881    ) -> gpui::Div {
882        let mut popup = div()
883            .absolute()
884            .top(px(48.0))
885            .left(px(0.0))
886            .min_w(px(240.0))
887            .py(px(4.0))
888            .bg(theme.bg_panel_alt.clone())
889            .border_1()
890            .border_color(theme.border_strong)
891            .rounded(px(6.0))
892            .flex()
893            .flex_col();
894
895        if include_auto {
896            let module_id_clear = module_id.to_string();
897            let key_clear = key.to_string();
898            let clear_id: SharedString =
899                SharedString::from(format!("tts-dropdown-clear-{}-{}", module_id, key));
900            popup = popup.child(
901                div()
902                    .id(gpui::ElementId::from(clear_id))
903                    .px(px(12.0))
904                    .py(px(5.0))
905                    .text_size(px(11.0))
906                    .text_color(theme.fg_muted)
907                    .hover(|s| s.bg(theme.bg_row_hover))
908                    .child("(automático)")
909                    .on_click(cx.listener(move |this, _: &ClickEvent, _, cx| {
910                        this.select_string_value(
911                            module_id_clear.clone(),
912                            key_clear.clone(),
913                            None,
914                            cx,
915                        );
916                    })),
917            );
918
919            if !options.is_empty() {
920                popup = popup.child(
921                    div()
922                        .my(px(3.0))
923                        .h(px(1.0))
924                        .w_full()
925                        .bg(theme.border),
926                );
927            }
928        }
929
930        for (value, opt_label) in options {
931            let module_id_pick = module_id.to_string();
932            let key_pick = key.to_string();
933            let opt_value = value.clone();
934            let row_id: SharedString =
935                SharedString::from(format!("tts-dropdown-opt-{}-{}-{}", module_id, key, value));
936            popup = popup.child(
937                div()
938                    .id(gpui::ElementId::from(row_id))
939                    .px(px(12.0))
940                    .py(px(5.0))
941                    .text_size(px(11.0))
942                    .text_color(theme.fg_text)
943                    .hover(|s| s.bg(theme.bg_row_hover))
944                    .child(SharedString::from(opt_label.clone()))
945                    .on_click(cx.listener(move |this, _: &ClickEvent, _, cx| {
946                        this.select_string_value(
947                            module_id_pick.clone(),
948                            key_pick.clone(),
949                            Some(opt_value.clone()),
950                            cx,
951                        );
952                    })),
953            );
954        }
955
956        popup
957    }
958}
959
960fn display_row(theme: &Theme, label: &str, value: &str) -> gpui::Div {
961    div()
962        .flex()
963        .flex_row()
964        .items_center()
965        .gap(px(8.0))
966        .px(px(6.0))
967        .py(px(3.0))
968        .child(
969            div()
970                .text_size(px(11.0))
971                .text_color(theme.fg_text)
972                .child(SharedString::from(label.to_string())),
973        )
974        .child(
975            div()
976                .ml_auto()
977                .text_size(px(10.0))
978                .text_color(theme.fg_muted)
979                .child(SharedString::from(value.to_string())),
980        )
981}