⎇ d3649bfd1a80153652c2a028315369bb4b184024
crates/modules/tahuantinsuyu/tahuantinsuyu-panel/src/lib.rs
History
Who touched what
raw
feat(tahuantinsuyu): fase 20 — accordion + lunar shift + CompositeModule + 90 ciudades
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}