//! `cosmobiologia-panel` — control panel inferior de la app. //! //! Lee los módulos disponibles para la carta activa (vía //! [`cosmobiologia_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 y al `module_configs` del shell. //! //! ## Estado interno //! //! - `toggle_state: HashMap<(module_id, key), bool>` — valor actual de //! cada toggle. Se inicializa lazy desde los defaults del módulo al //! cambiar de `ChartKind`. //! - `slider_state: HashMap<(module_id, key), f64>` — valor actual de //! cada slider. El shell puede sobreescribirlo via [`Self::set_slider`] //! cuando cambia la carta activa (ej. inicializar `target_age_years` //! con la edad actual del sujeto). //! - `slider_drag: Option` — slider que está bajo drag //! activo. Mutuamente excluyente: solo se arrastra un slider a la vez. #![forbid(unsafe_code)] #![warn(rust_2018_idioms)] use std::collections::HashMap; use gpui::{ Bounds, ClickEvent, Context, EventEmitter, IntoElement, MouseButton, MouseDownEvent, MouseMoveEvent, MouseUpEvent, ParentElement, Pixels, Point, Render, SharedString, Styled, Window, canvas, div, prelude::*, px, }; use cosmobiologia_model::ChartKind; use cosmobiologia_modules::{Control, Registry, SelectOption}; 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, }, /// Click sobre un `Control::Action`. El shell decide qué hacer /// (típicamente: capturar la carta derivada del overlay como /// `FreeChart`). Action { module_id: String, key: String, }, } /// Opción que el host inyecta al panel para que los `Control::ChartPicker` /// puedan mostrar el dropdown. El `id` es el ULID stringificado de la /// carta; el `label` es lo que se muestra en el dropdown. #[derive(Clone, Debug)] pub struct ChartOption { pub id: String, pub label: String, } // ===================================================================== // Estado interno // ===================================================================== #[derive(Clone, Debug)] struct SliderDrag { module_id: String, key: String, min: f64, max: f64, } // ===================================================================== // Widget // ===================================================================== pub struct ControlPanel { active_kind: Option, toggle_state: HashMap<(String, String), bool>, slider_state: HashMap<(String, String), f64>, slider_drag: Option, /// Opciones globales para todos los `ChartPicker` — las inyecta el /// shell vía [`Self::set_chart_options`]. Compartido entre todos /// los pickers porque típicamente representan "todas las cartas /// del DB" sin filtros por módulo. chart_options: Vec, /// Valor actual de cualquier control basado en string (ChartPicker /// y Select comparten storage). `None` = sin selección — el render /// muestra placeholder ("automático" en picker, default-label en /// select). string_state: HashMap<(String, String), Option>, /// Si hay un dropdown abierto, su (module_id, key). Mutuamente /// excluyente: solo uno abierto a la vez en todo el panel. dropdown_open: Option<(String, String)>, /// Overrides explícitos del estado expanded/collapsed por módulo. /// La semántica del default (sin override) está en /// [`Self::is_collapsed`]: natal y módulos enabled = expanded; /// el resto collapsed. collapse_overrides: HashMap, registry: Registry, } impl EventEmitter for ControlPanel {} impl ControlPanel { pub fn new(cx: &mut Context<'_, Self>) -> Self { cx.observe_global::(|_, cx| cx.notify()).detach(); Self { active_kind: None, toggle_state: HashMap::new(), slider_state: HashMap::new(), slider_drag: None, chart_options: Vec::new(), string_state: HashMap::new(), dropdown_open: None, collapse_overrides: HashMap::new(), registry: Registry::with_builtins(), } } /// Decide si el card de un módulo debe pintarse collapsed (solo /// header) o expanded (header + controles). La regla: si el usuario /// puso un override explícito lo respetamos; sino, natal va /// expanded siempre y el resto solo si su toggle "enabled" es true. fn is_collapsed(&self, module_id: &str) -> bool { if let Some(v) = self.collapse_overrides.get(module_id) { return *v; } if module_id == "natal" { return false; } !self .toggle_state .get(&(module_id.to_string(), "enabled".to_string())) .copied() .unwrap_or(false) } fn toggle_collapsed(&mut self, module_id: String, cx: &mut Context<'_, Self>) { let current = self.is_collapsed(&module_id); self.collapse_overrides.insert(module_id, !current); cx.notify(); } pub fn set_active_kind(&mut self, kind: Option, cx: &mut Context<'_, Self>) { if self.active_kind != kind { if let Some(k) = kind { for m in self.registry.for_kind(k) { for c in m.controls() { match c { Control::Toggle { key, default, .. } => { self.toggle_state .entry((m.id().to_string(), key)) .or_insert(default); } Control::Slider { key, default, .. } => { self.slider_state .entry((m.id().to_string(), key)) .or_insert(default); } Control::ChartPicker { key, .. } => { self.string_state .entry((m.id().to_string(), key)) .or_insert(None); } Control::Select { key, default, .. } => { self.string_state .entry((m.id().to_string(), key)) .or_insert(Some(default)); } _ => {} } } } } } self.active_kind = kind; // Cerrar cualquier dropdown abierto al cambiar de carta. self.dropdown_open = None; cx.notify(); } /// Setea un toggle desde afuera (sin emitir evento). Usado por el /// shell para sincronizar cuando el canvas se autotoggleó via hotkey. pub fn set_toggle(&mut self, module_id: &str, key: &str, value: bool, cx: &mut Context<'_, Self>) { self.toggle_state .insert((module_id.to_string(), key.to_string()), value); cx.notify(); } /// Setea un slider desde afuera (sin emitir evento). El shell la /// usa, por ejemplo, para inicializar `progression.target_age_years` /// con la edad actual del sujeto al cargar una carta nueva. pub fn set_slider(&mut self, module_id: &str, key: &str, value: f64, cx: &mut Context<'_, Self>) { self.slider_state .insert((module_id.to_string(), key.to_string()), value); cx.notify(); } /// Reemplaza el catálogo de opciones que muestran los /// `Control::ChartPicker`. El shell la llama cada vez que la /// jerarquía de cartas cambia (crear/borrar) para que el dropdown /// quede al día sin necesidad de re-instanciar el panel. pub fn set_chart_options(&mut self, options: Vec, cx: &mut Context<'_, Self>) { self.chart_options = options; cx.notify(); } /// Setea el valor de un control basado en string (ChartPicker o /// Select) desde afuera, sin emitir. El shell la usa para restaurar /// el valor persistido al cargar una carta. pub fn set_string( &mut self, module_id: &str, key: &str, value: Option, cx: &mut Context<'_, Self>, ) { self.string_state .insert((module_id.to_string(), key.to_string()), value); cx.notify(); } /// Alias retrocompatible — los call-sites antiguos del shell usaban /// `set_chart_picker`. Funcionalmente idéntico a [`Self::set_string`]. pub fn set_chart_picker( &mut self, module_id: &str, key: &str, chart_id: Option, cx: &mut Context<'_, Self>, ) { self.set_string(module_id, key, chart_id, cx); } // ----- internos: handlers ----- fn on_toggle_click(&mut self, module_id: String, key: String, cx: &mut Context<'_, Self>) { 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(); } fn start_slider_drag( &mut self, module_id: String, key: String, min: f64, max: f64, bounds: Bounds, position: Point, cx: &mut Context<'_, Self>, ) { self.slider_drag = Some(SliderDrag { module_id: module_id.clone(), key: key.clone(), min, max, }); self.apply_slider_position(bounds, position, cx); } fn continue_slider_drag( &mut self, bounds: Bounds, position: Point, cx: &mut Context<'_, Self>, ) { if self.slider_drag.is_some() { self.apply_slider_position(bounds, position, cx); } } fn end_slider_drag(&mut self, cx: &mut Context<'_, Self>) { if self.slider_drag.take().is_some() { cx.notify(); } } fn toggle_dropdown_open(&mut self, module_id: String, key: String, cx: &mut Context<'_, Self>) { let key_pair = (module_id, key); let new_state = match self.dropdown_open.as_ref() { Some(open) if open == &key_pair => None, _ => Some(key_pair), }; self.dropdown_open = new_state; cx.notify(); } fn select_string_value( &mut self, module_id: String, key: String, value: Option, cx: &mut Context<'_, Self>, ) { self.string_state .insert((module_id.clone(), key.clone()), value.clone()); self.dropdown_open = None; let json_value = match value { Some(s) => serde_json::Value::String(s), None => serde_json::Value::Null, }; cx.emit(PanelEvent::ControlChanged { module_id, key, value: json_value, }); cx.notify(); } fn apply_slider_position( &mut self, bounds: Bounds, position: Point, cx: &mut Context<'_, Self>, ) { let Some(drag) = self.slider_drag.as_ref().cloned() else { return; }; let track_x: f32 = bounds.origin.x.into(); let track_w: f32 = bounds.size.width.into(); let mouse_x: f32 = position.x.into(); let fraction = if track_w > 0.0 { ((mouse_x - track_x) / track_w).clamp(0.0, 1.0) as f64 } else { 0.0 }; let value = drag.min + fraction * (drag.max - drag.min); self.slider_state .insert((drag.module_id.clone(), drag.key.clone()), value); cx.emit(PanelEvent::ControlChanged { module_id: drag.module_id, key: drag.key, value: serde_json::json!(value), }); cx.notify(); } } // ===================================================================== // Render // ===================================================================== const SLIDER_TRACK_W: f32 = 140.0; const SLIDER_TRACK_H: f32 = 8.0; const SLIDER_THUMB: f32 = 12.0; impl Render for ControlPanel { fn render(&mut self, _w: &mut Window, cx: &mut Context<'_, Self>) -> impl IntoElement { let theme = Theme::global(cx).clone(); 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() .id("tts-panel-body") .flex_grow() // `min_h(0)` libera al body de la altura intrínseca de su // contenido — sin esto el flex_col padre lo expandiría hasta // fit-content y el scroll nunca aparecería. .min_h(px(0.0)) .overflow_y_scroll() .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<'_, Self>, ) -> gpui::Div { let collapsed = self.is_collapsed(module_id); let chevron = if collapsed { "▸" } else { "▾" }; let header_id: SharedString = SharedString::from(format!("tts-module-header-{}", module_id)); let module_id_for_listener = module_id.to_string(); let header = div() .id(gpui::ElementId::from(header_id)) .flex() .flex_row() .items_center() .gap(px(8.0)) .hover(|s| s.bg(theme.bg_row_hover)) .rounded(px(4.0)) .px(px(4.0)) .py(px(2.0)) .child( div() .text_size(px(11.0)) .text_color(theme.fg_muted) .child(chevron), ) .child( div() .flex() .flex_col() .flex_grow() .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())), ), ) .on_click(cx.listener(move |this, _: &ClickEvent, _, cx| { this.toggle_collapsed(module_id_for_listener.clone(), cx); })); let mut card = div() .min_w(px(260.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); if !collapsed { 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)); } card = card.child(body); } card } fn render_control( &self, theme: &Theme, module_id: &str, c: &Control, cx: &mut Context<'_, Self>, ) -> gpui::Div { match c { Control::Toggle { key, label, default, hotkey, } => self.render_toggle(theme, module_id, key, label, *default, hotkey.as_deref(), cx), Control::Slider { key, label, min, max, default, .. } => self.render_slider(theme, module_id, key, label, *min, *max, *default, cx), Control::ChartPicker { key, label } => { self.render_chart_picker(theme, module_id, key, label, cx) } Control::Select { key, label, options, default, } => self.render_select(theme, module_id, key, label, options, default, cx), Control::TextInput { label, default, .. } => display_row(theme, label, default), Control::Action { key, label } => { self.render_action(theme, module_id, key, label, cx) } } } fn render_action( &self, theme: &Theme, module_id: &str, key: &str, label: &str, cx: &mut Context<'_, Self>, ) -> gpui::Div { let id_str: SharedString = SharedString::from(format!("tts-action-{}-{}", module_id, key)); let id_for_listener = (module_id.to_string(), key.to_string()); let btn = div() .id(gpui::ElementId::from(id_str)) .flex() .flex_row() .items_center() .justify_center() .px(px(10.0)) .py(px(5.0)) .rounded(px(6.0)) .bg(theme.bg_button()) .hover(|s| s.bg(theme.bg_button_hover())) .border_1() .border_color(theme.border) .text_size(px(11.0)) .text_color(theme.fg_text) .child(SharedString::from(label.to_string())) .on_click(cx.listener(move |_this, _: &ClickEvent, _, cx| { let (m, k) = id_for_listener.clone(); cx.emit(PanelEvent::Action { module_id: m, key: k, }); })); // Wrap en Div plano para que el tipo coincida con el resto // de los renderers (`render_control` espera `gpui::Div`). div().child(btn) } fn render_toggle( &self, theme: &Theme, module_id: &str, key: &str, label: &str, default: bool, hotkey: Option<&str>, cx: &mut Context<'_, Self>, ) -> gpui::Div { let active = self .toggle_state .get(&(module_id.to_string(), key.to_string())) .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.to_string()); let hotkey_str = hotkey .map(|h| format!("[{}]", h)) .unwrap_or_default(); 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.to_string())), ) .child( div() .ml_auto() .text_size(px(10.0)) .text_color(theme.fg_muted) .child(SharedString::from(hotkey_str)), ) .on_click(cx.listener(move |this, _: &ClickEvent, _, cx| { let (m, k) = id_for_listener.clone(); this.on_toggle_click(m, k, cx); })); div().child(row) } fn render_slider( &self, theme: &Theme, module_id: &str, key: &str, label: &str, min: f64, max: f64, default: f64, cx: &mut Context<'_, Self>, ) -> gpui::Div { let value = self .slider_state .get(&(module_id.to_string(), key.to_string())) .copied() .unwrap_or(default); let range = (max - min).max(f64::EPSILON); let fraction = ((value - min) / range).clamp(0.0, 1.0) as f32; let filled_w = fraction * SLIDER_TRACK_W; let thumb_x = (fraction * SLIDER_TRACK_W) - SLIDER_THUMB / 2.0; let entity = cx.entity(); let mod_for_mouse = module_id.to_string(); let key_for_mouse = key.to_string(); let canvas_overlay = canvas( move |_bounds: Bounds, _w, _cx| (), move |bounds: Bounds, _, window, _| { // MouseDown sobre el track → start drag + valor inmediato. let entity_d = entity.clone(); let mod_d = mod_for_mouse.clone(); let key_d = key_for_mouse.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.start_slider_drag( mod_d.clone(), key_d.clone(), min, max, bounds, ev.position, cx, ); }); }); // MouseMove (durante drag) → continuar solo si ESTE // slider es el que está bajo drag. let entity_m = entity.clone(); let mod_m = mod_for_mouse.clone(); let key_m = key_for_mouse.clone(); window.on_mouse_event(move |ev: &MouseMoveEvent, _, _w, cx| { if !ev.dragging() { return; } entity_m.update(cx, |this, cx| { let is_mine = this .slider_drag .as_ref() .map(|d| d.module_id == mod_m && d.key == key_m) .unwrap_or(false); if is_mine { this.continue_slider_drag(bounds, ev.position, cx); } }); }); // MouseUp anywhere → terminar drag. let entity_u = entity.clone(); window.on_mouse_event(move |_: &MouseUpEvent, _, _w, cx| { entity_u.update(cx, |this, cx| this.end_slider_drag(cx)); }); }, ) .absolute() .w(px(SLIDER_TRACK_W)) .h(px(SLIDER_TRACK_H)); let track = div() .relative() .w(px(SLIDER_TRACK_W)) .h(px(SLIDER_TRACK_H)) .bg(theme.bg_input()) .rounded(px(SLIDER_TRACK_H / 2.0)) .child( div() .absolute() .left(px(0.0)) .top(px(0.0)) .h(px(SLIDER_TRACK_H)) .w(px(filled_w)) .bg(theme.accent) .rounded(px(SLIDER_TRACK_H / 2.0)), ) .child( div() .absolute() .left(px(thumb_x)) .top(px(-2.0)) .w(px(SLIDER_THUMB)) .h(px(SLIDER_THUMB)) .rounded(px(SLIDER_THUMB / 2.0)) .bg(theme.fg_text) .border_1() .border_color(theme.border_strong), ) .child(canvas_overlay); div() .flex() .flex_col() .gap(px(4.0)) .px(px(6.0)) .py(px(3.0)) .child( 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.to_string())), ) .child( div() .ml_auto() .text_size(px(10.0)) .text_color(theme.fg_muted) .child(SharedString::from(format!( "{:.1} ({}…{})", value, min, max ))), ), ) .child(track) } } impl ControlPanel { fn render_chart_picker( &self, theme: &Theme, module_id: &str, key: &str, label: &str, cx: &mut Context<'_, Self>, ) -> gpui::Div { let options: Vec<(String, String)> = self .chart_options .iter() .map(|o| (o.id.clone(), o.label.clone())) .collect(); self.render_dropdown( theme, module_id, key, label, "(automático)", &options, true, // incluir opción "(automático)" en el popup cx, ) } fn render_select( &self, theme: &Theme, module_id: &str, key: &str, label: &str, options: &[SelectOption], default: &str, cx: &mut Context<'_, Self>, ) -> gpui::Div { let opts: Vec<(String, String)> = options .iter() .map(|o| (o.value.clone(), o.label.clone())) .collect(); let placeholder = options .iter() .find(|o| o.value == default) .map(|o| o.label.clone()) .unwrap_or_else(|| default.to_string()); self.render_dropdown(theme, module_id, key, label, &placeholder, &opts, false, cx) } #[allow(clippy::too_many_arguments)] fn render_dropdown( &self, theme: &Theme, module_id: &str, key: &str, label: &str, placeholder: &str, options: &[(String, String)], include_auto: bool, cx: &mut Context<'_, Self>, ) -> gpui::Div { let current_value = self .string_state .get(&(module_id.to_string(), key.to_string())) .cloned() .flatten(); let current_label = current_value .as_ref() .and_then(|v| options.iter().find(|(val, _)| val == v).map(|(_, l)| l.clone())) .unwrap_or_else(|| placeholder.to_string()); let is_open = self .dropdown_open .as_ref() .map(|(m, k)| m == module_id && k == key) .unwrap_or(false); let module_id_btn = module_id.to_string(); let key_btn = key.to_string(); let btn_id: SharedString = SharedString::from(format!("tts-dropdown-btn-{}-{}", module_id, key)); let button = div() .id(gpui::ElementId::from(btn_id)) .px(px(10.0)) .py(px(5.0)) .rounded(px(4.0)) .bg(theme.bg_button()) .hover(|s| s.bg(theme.bg_button_hover())) .border_1() .border_color(if is_open { theme.accent_strong } else { theme.border }) .text_size(px(11.0)) .text_color(theme.fg_text) .child(SharedString::from(format!("▾ {}", current_label))) .on_click(cx.listener(move |this, _: &ClickEvent, _, cx| { this.toggle_dropdown_open(module_id_btn.clone(), key_btn.clone(), cx); })); let mut wrapper = div() .relative() .flex() .flex_col() .gap(px(2.0)) .child( div() .text_size(px(10.0)) .text_color(theme.fg_muted) .child(SharedString::from(label.to_string())), ) .child(button); if is_open { wrapper = wrapper.child(self.render_dropdown_popup( theme, module_id, key, options, include_auto, cx, )); } div().px(px(6.0)).py(px(3.0)).child(wrapper) } fn render_dropdown_popup( &self, theme: &Theme, module_id: &str, key: &str, options: &[(String, String)], include_auto: bool, cx: &mut Context<'_, Self>, ) -> gpui::Div { let mut popup = div() .absolute() .top(px(48.0)) .left(px(0.0)) .min_w(px(240.0)) .py(px(4.0)) .bg(theme.bg_panel_alt.clone()) .border_1() .border_color(theme.border_strong) .rounded(px(6.0)) .flex() .flex_col(); if include_auto { let module_id_clear = module_id.to_string(); let key_clear = key.to_string(); let clear_id: SharedString = SharedString::from(format!("tts-dropdown-clear-{}-{}", module_id, key)); popup = popup.child( div() .id(gpui::ElementId::from(clear_id)) .px(px(12.0)) .py(px(5.0)) .text_size(px(11.0)) .text_color(theme.fg_muted) .hover(|s| s.bg(theme.bg_row_hover)) .child("(automático)") .on_click(cx.listener(move |this, _: &ClickEvent, _, cx| { this.select_string_value( module_id_clear.clone(), key_clear.clone(), None, cx, ); })), ); if !options.is_empty() { popup = popup.child( div() .my(px(3.0)) .h(px(1.0)) .w_full() .bg(theme.border), ); } } for (value, opt_label) in options { let module_id_pick = module_id.to_string(); let key_pick = key.to_string(); let opt_value = value.clone(); let row_id: SharedString = SharedString::from(format!("tts-dropdown-opt-{}-{}-{}", module_id, key, value)); popup = popup.child( div() .id(gpui::ElementId::from(row_id)) .px(px(12.0)) .py(px(5.0)) .text_size(px(11.0)) .text_color(theme.fg_text) .hover(|s| s.bg(theme.bg_row_hover)) .child(SharedString::from(opt_label.clone())) .on_click(cx.listener(move |this, _: &ClickEvent, _, cx| { this.select_string_value( module_id_pick.clone(), key_pick.clone(), Some(opt_value.clone()), cx, ); })), ); } popup } } fn display_row(theme: &Theme, label: &str, value: &str) -> gpui::Div { 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.to_string())), ) .child( div() .ml_auto() .text_size(px(10.0)) .text_color(theme.fg_muted) .child(SharedString::from(value.to_string())), ) }