⎇ e0c5c02b8e2345247740805fdbb1fb9547ccb6b2
crates/modules/tahuantinsuyu/tahuantinsuyu-panel/src/lib.rs
Historia
Quién tocó qué
crudo
feat(tahuantinsuyu): fase 8 — slider interactivo + slider de edad en progression
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}