⎇ ea074d7d576114d3335c4b84d81725dffe09a2ed
crates/modules/ui_engine/widgets/meta-form/src/lib.rs
Ñawpa willay
Pi imata llamkarqan
ch'uya
feat(yahweh): caret blinking + slots ornament en theme + MetaApp full themed
1//! `yahweh-widget-meta-form` — widget GPUI de metainterfaz.
2//!
3//! Renderea cualquier conjunto de [`yahweh_meta_schema::Module`] sobre
4//! cualquier impl de [`yahweh_meta_runtime::MetaBackend`]: sidebar con
5//! menús, list/form views, modal de delete, EntityRef selector.
6//!
7//! El widget es **app-agnostic**: no asume Nakui, ni storage
8//! particular, ni lifecycle de bootstrapping. El binario que lo
9//! consume:
10//!
11//! 1. Carga sus módulos (típicamente via `brahman-cards`).
12//! 2. Construye su backend concreto (typicamente wireado a un
13//! store/log/executor de su stack).
14//! 3. Crea `MetaApp::new(modules, backend, initial_toast,
15//! initial_error, cx)` y lo monta como root view de GPUI.
16//!
17//! Para una metainterfaz Nakui-completa ver `nakui-ui` (binario
18//! shell que provee `NakuiBackend`).
19
20#![forbid(unsafe_code)]
21
22use std::collections::BTreeMap;
23
24use gpui::{
25 div, prelude::*, px, ClickEvent, Context, Entity, IntoElement, KeyDownEvent, Render,
26 SharedString, Window,
27};
28
29use serde_json::Value;
30use uuid::Uuid;
31use yahweh_meta_runtime::{
32 compute_clear_fields, compute_field_delta, human_label_for_record, parse_field_value,
33 render_value, resolve_param_value, short_uuid, validate_entity_refs, value_to_input_text,
34 MetaBackend, WriteOutcome,
35};
36use yahweh_meta_schema::{Action, FieldKind, FieldSpec, FormView, ListView, Module, View};
37use yahweh_theme::Theme;
38use yahweh_widget_banner::{banner_themed, themed_colors, Banner};
39use yahweh_widget_text_input::TextInput;
40use yahweh_widget_theme_switcher::theme_switcher;
41
42/// Estado del runtime de UI. Toda la persistencia/ejecución está
43/// detrás del trait [`MetaBackend`]; este struct sólo conoce GPUI
44/// state y el schema de los módulos.
45///
46/// Genérico sobre el backend `B`. Un binario decide qué backend
47/// proveer (Nakui via `NakuiBackend`, mock para tests, etc.).
48pub struct MetaApp<B: MetaBackend> {
49 /// Módulos cargados, ordenados por id.
50 modules: Vec<Module>,
51 /// Backend que ejecuta seed/update/delete/morphism.
52 backend: B,
53 /// (módulo idx, vista key) actualmente activos.
54 active: Option<(usize, String)>,
55 /// Inputs vivos para el form actual: nombre del campo → TextInput.
56 /// Se reemplaza al cambiar de vista (drop de los anteriores).
57 form_inputs: BTreeMap<String, Entity<TextInput>>,
58 /// Si está set, el próximo render del Form pre-llena los inputs
59 /// con los valores del record indicado, y `commit_seed` emite
60 /// un `update` (no un seed nuevo). Limpia al cambiar de view o
61 /// tras submit exitoso.
62 editing: Option<(String, Uuid)>,
63 /// Si está set, el banner modal de confirmación de delete está
64 /// activo: `(entity, id)` del record que el usuario marcó para
65 /// borrar. Permanece hasta que el usuario click [Confirmar]
66 /// (ejecuta `commit_delete` y limpia) o [Cancelar] (sólo limpia).
67 /// Navegación a otra view también cancela.
68 pending_delete: Option<(String, Uuid)>,
69 /// Mensaje toast al pie (success de submit, error de carga, etc.).
70 toast: Option<SharedString>,
71 /// Si la carga de módulos falló al inicio.
72 load_error: Option<SharedString>,
73}
74
75impl<B: MetaBackend> MetaApp<B> {
76 /// Constructor del widget. El caller pre-construye sus módulos +
77 /// backend + cualquier mensaje inicial que quiera mostrar al
78 /// usuario al abrir (típicamente el toast del replay y el error
79 /// banner de cosas que fallaron de bootstrap).
80 ///
81 /// La active view default es la primera entry del menú del
82 /// primer módulo (orden lexicográfico por id).
83 pub fn new(
84 modules: Vec<Module>,
85 backend: B,
86 initial_toast: Option<String>,
87 initial_error: Option<String>,
88 _cx: &mut Context<Self>,
89 ) -> Self {
90 let active = modules
91 .first()
92 .and_then(|m| m.menu.first().map(|item| (0usize, item.view.clone())));
93 Self {
94 modules,
95 backend,
96 active,
97 form_inputs: BTreeMap::new(),
98 editing: None,
99 pending_delete: None,
100 toast: initial_toast.map(SharedString::from),
101 load_error: initial_error.map(SharedString::from),
102 }
103 }
104
105 /// Cambia la vista activa. Si la nueva vista es un Form, crea
106 /// `TextInput` entities para cada field. Pre-llena con valores
107 /// del record si hay `editing` para esa entity; si no, usa el
108 /// `default` del schema.
109 /// Drop de los inputs anteriores ocurre al sobreescribir el map.
110 fn select_view(&mut self, mod_idx: usize, view_key: String, cx: &mut Context<Self>) {
111 self.active = Some((mod_idx, view_key.clone()));
112 self.toast = None;
113 // Navegar a otra view cancela cualquier delete pendiente:
114 // el record marcado puede no estar visible en la nueva view.
115 self.pending_delete = None;
116 self.form_inputs = BTreeMap::new();
117 if let Some(module) = self.modules.get(mod_idx) {
118 if let Some(View::Form(form)) = module.views.get(&view_key) {
119 // Snapshot del record si estamos editando esta entity.
120 let editing_record: Option<Value> = self.editing.as_ref().and_then(|(e, id)| {
121 if e == &form.entity {
122 self.backend.load_record(e, *id)
123 } else {
124 None
125 }
126 });
127 for f in &form.fields {
128 let initial = if let Some(rec) = &editing_record {
129 rec.get(&f.name)
130 .map(value_to_input_text)
131 .unwrap_or_else(|| f.default.clone().unwrap_or_default())
132 } else {
133 f.default.clone().unwrap_or_default()
134 };
135 let input = cx.new(|cx| TextInput::new(initial, cx));
136 self.form_inputs.insert(f.name.clone(), input);
137 }
138 } else {
139 // Cambiar a una view que no es Form invalida el editing
140 // pendiente.
141 self.editing = None;
142 }
143 }
144 cx.notify();
145 }
146
147 /// Inicia un edit del record: setea `editing` y abre la primera
148 /// view de tipo Form del módulo (convención: la del schema).
149 fn open_edit(
150 &mut self,
151 mod_idx: usize,
152 entity: String,
153 id: Uuid,
154 cx: &mut Context<Self>,
155 ) {
156 self.editing = Some((entity.clone(), id));
157 let form_view_key = self.modules.get(mod_idx).and_then(|m| {
158 m.views
159 .iter()
160 .find_map(|(key, v)| match v {
161 View::Form(form) if form.entity == entity => Some(key.clone()),
162 _ => None,
163 })
164 });
165 match form_view_key {
166 Some(key) => self.select_view(mod_idx, key, cx),
167 None => {
168 self.toast = Some(SharedString::from(format!(
169 "no hay form view para entity '{entity}' en este módulo"
170 )));
171 self.editing = None;
172 cx.notify();
173 }
174 }
175 }
176
177 /// Borra un record. Emite Morphism con un FieldOp::Delete + lo
178 /// aplica al store via `apply` (no via remove directo, mantiene
179 /// el modelo de "todo cambio post-seed pasa por ops").
180 /// Borra un record vía `MetaBackend::delete`. Devuelve el outcome
181 /// del backend (incluye eventual `post_status` del compact tick).
182 fn commit_delete(
183 &mut self,
184 entity: &str,
185 id: Uuid,
186 ) -> Result<WriteOutcome, String> {
187 self.backend.delete(entity, id)
188 }
189
190 /// Aplica una acción (click en menú, botón de form, action de
191 /// list). Mutaciones contra el backend ocurren acá.
192 pub fn apply_action(&mut self, action: Action, cx: &mut Context<Self>) {
193 let mod_idx = match self.active.as_ref() {
194 Some((i, _)) => *i,
195 None => return,
196 };
197 match action {
198 Action::OpenView { view, .. } => {
199 // Salir a otra view cancela el edit pendiente.
200 self.editing = None;
201 self.select_view(mod_idx, view, cx);
202 }
203 Action::SeedEntity { entity, next_view } => {
204 let was_editing = self.editing.is_some();
205 match self.commit_seed(mod_idx, &entity, cx) {
206 Ok(outcome) => {
207 let toast_msg = format_seed_toast(&entity, was_editing, &outcome);
208 self.toast = Some(append_compact_msg(toast_msg, outcome.post_status));
209 // Limpia editing tras un commit exitoso —
210 // el record ya está sincronizado (incluso
211 // un NoChange cierra el modo edit).
212 self.editing = None;
213 if let Some(v) = next_view {
214 self.select_view(mod_idx, v, cx);
215 } else {
216 for input in self.form_inputs.values() {
217 input.update(cx, |inp, cx| inp.set_text("", cx));
218 }
219 }
220 }
221 Err(e) => {
222 self.toast = Some(SharedString::from(format!("error: {e}")));
223 }
224 }
225 cx.notify();
226 }
227 Action::Morphism {
228 name,
229 inputs,
230 params,
231 next_view,
232 } => {
233 match self.commit_morphism(mod_idx, &name, &inputs, ¶ms, cx) {
234 Ok(outcome) => {
235 let base = format!(
236 "morphism '{name}' OK ({} op(s) aplicadas)",
237 outcome.changed
238 );
239 self.toast = Some(append_compact_msg(base, outcome.post_status));
240 if let Some(v) = next_view {
241 self.select_view(mod_idx, v, cx);
242 }
243 }
244 Err(e) => {
245 self.toast = Some(SharedString::from(format!(
246 "morphism '{name}' falló: {e}"
247 )));
248 }
249 }
250 cx.notify();
251 }
252 }
253 }
254
255 /// Despacha un morphism via el backend. Resuelve inputs (UUIDs)
256 /// + params (Value object) leyendo los TextInput del form.
257 fn commit_morphism(
258 &mut self,
259 mod_idx: usize,
260 morphism: &str,
261 inputs_map: &BTreeMap<String, String>,
262 params_fields: &[String],
263 cx: &mut Context<Self>,
264 ) -> Result<WriteOutcome, String> {
265 let module = self
266 .modules
267 .get(mod_idx)
268 .ok_or_else(|| "módulo inválido".to_string())?;
269 let module_id = module.id.clone();
270
271 // Resolver inputs: por cada (role, field_name), parsear el
272 // value del input como Uuid.
273 let mut inputs: BTreeMap<String, Uuid> = BTreeMap::new();
274 for (role, field_name) in inputs_map {
275 let raw = self
276 .form_inputs
277 .get(field_name)
278 .map(|inp| inp.read(&*cx).text().to_string())
279 .ok_or_else(|| format!("input field '{field_name}' no existe en el form"))?;
280 let id = Uuid::parse_str(raw.trim()).map_err(|_| {
281 format!(
282 "input '{role}' (field '{field_name}'): '{raw}' no es UUID válido"
283 )
284 })?;
285 inputs.insert(role.clone(), id);
286 }
287
288 // Resolver params: si la lista está vacía, todos los fields
289 // del form que no estén en `inputs_map` van a params.
290 let input_field_set: std::collections::BTreeSet<&String> = inputs_map.values().collect();
291 let mut params_obj = serde_json::Map::new();
292 let field_iter: Vec<String> = if params_fields.is_empty() {
293 self.form_inputs
294 .keys()
295 .filter(|k| !input_field_set.contains(*k))
296 .cloned()
297 .collect()
298 } else {
299 params_fields.to_vec()
300 };
301
302 // FieldSpec del Form view activo para parseo estricto por kind.
303 let active_form_fields: Option<Vec<FieldSpec>> =
304 self.active.as_ref().and_then(|(_, vk)| {
305 module.views.get(vk).and_then(|v| match v {
306 View::Form(f) => Some(f.fields.clone()),
307 _ => None,
308 })
309 });
310
311 for field_name in field_iter {
312 let raw = self
313 .form_inputs
314 .get(&field_name)
315 .map(|inp| inp.read(&*cx).text().to_string())
316 .unwrap_or_default();
317 let spec = active_form_fields
318 .as_ref()
319 .and_then(|fs| fs.iter().find(|f| f.name == field_name));
320 let value = resolve_param_value(&field_name, &raw, spec)?;
321 params_obj.insert(field_name, value);
322 }
323
324 self.backend
325 .morphism(&module_id, morphism, inputs, Value::Object(params_obj))
326 }
327
328 /// Construye un payload desde los TextInput vivos y delega al
329 /// backend (`seed` para alta nueva, `update` con set+clear para
330 /// edit). Devuelve el `WriteOutcome` del backend (incluye
331 /// `changed` y `post_status` para el toast).
332 fn commit_seed(
333 &mut self,
334 mod_idx: usize,
335 entity: &str,
336 cx: &mut Context<Self>,
337 ) -> Result<WriteOutcome, String> {
338 let module = &self.modules[mod_idx];
339 let spec_fields: Vec<FieldSpec> = match self.active.as_ref() {
340 Some((_, view_key)) => match module.views.get(view_key) {
341 Some(View::Form(f)) => f.fields.clone(),
342 _ => return Err("la vista activa no es un Form".into()),
343 },
344 None => return Err("ninguna vista activa".into()),
345 };
346 let mut obj = serde_json::Map::new();
347 // Fields que el form deja vacíos y son optional: candidatos
348 // a Clear en el path de EDIT.
349 let mut to_clear: Vec<String> = Vec::new();
350 // EntityRef refs a validar tras parse loop (en una toma del
351 // backend en lugar de N).
352 let mut entity_refs: Vec<(String, String, Uuid)> = Vec::new();
353 for f in &spec_fields {
354 let raw = self
355 .form_inputs
356 .get(&f.name)
357 .map(|input| input.read(cx).text().to_string())
358 .unwrap_or_default();
359 if f.required && raw.trim().is_empty() {
360 return Err(format!("campo '{}' es obligatorio", f.label));
361 }
362 if raw.is_empty() && !f.required {
363 to_clear.push(f.name.clone());
364 continue;
365 }
366 let value = parse_field_value(f.kind, &raw)
367 .map_err(|e| format!("campo '{}': {e}", f.label))?;
368 if f.kind == FieldKind::EntityRef {
369 if let (Some(target), Some(uuid_str)) = (&f.ref_entity, value.as_str()) {
370 let id = Uuid::parse_str(uuid_str)
371 .expect("parse_field_value validated UUID");
372 entity_refs.push((f.label.clone(), target.clone(), id));
373 }
374 }
375 obj.insert(f.name.clone(), value);
376 }
377 // Validar EntityRefs contra el backend actual. Cierre wrappea
378 // backend.load_record para mantener la firma de
379 // validate_entity_refs (que es store-agnóstica).
380 if !entity_refs.is_empty() {
381 let backend = &self.backend;
382 validate_entity_refs(|e, id| backend.load_record(e, id), &entity_refs)?;
383 }
384
385 // Ramificación: edit (hay editing para esta entity) vs alta.
386 let editing_match = self.editing.as_ref().filter(|(e, _)| e == entity).cloned();
387
388 if let Some((_, id)) = editing_match {
389 // EDIT: cargar current, computar delta, llamar
390 // backend.update con set+clear pre-computados.
391 let current = self.backend.load_record(entity, id).unwrap_or(Value::Null);
392 let set_delta = compute_field_delta(¤t, &obj);
393 let clear_fields = compute_clear_fields(¤t, &to_clear);
394 self.backend.update(entity, id, set_delta, clear_fields)
395 } else {
396 // SEED: alta nueva — el backend genera el Uuid.
397 self.backend.seed(entity, obj)
398 }
399 }
400
401 /// Snapshot ordenado de records de una entity (proxy al backend).
402 fn list_rows(&self, entity: &str) -> Vec<(Uuid, Value)> {
403 self.backend.list_records(entity)
404 }
405}
406
407/// Formatea el toast para la rama Action::SeedEntity según el
408/// `WriteOutcome` del backend. `was_editing` distingue "creado"
409/// vs "actualizado" — el WriteOutcome solo no alcanza porque
410/// `seed` y `update` ambos devuelven `id = Some(...)`.
411fn format_seed_toast(entity: &str, was_editing: bool, outcome: &WriteOutcome) -> String {
412 let id_short = outcome
413 .id
414 .map(|id| short_uuid(&id))
415 .unwrap_or_default();
416 match (was_editing, outcome.changed) {
417 (false, _) => format!("creado {entity} {id_short}"),
418 (true, 0) => format!("{entity} {id_short} sin cambios — no log entry"),
419 (true, n) => format!("actualizado {entity} {id_short} ({n} campo(s))"),
420 }
421}
422
423/// Concatena el msg opcional del compact tick al toast del op
424/// original con `"; "` separator.
425fn append_compact_msg(base: String, compact_msg: Option<String>) -> SharedString {
426 match compact_msg {
427 Some(m) => SharedString::from(format!("{base}; {m}")),
428 None => SharedString::from(base),
429 }
430}
431
432/// Walker dentro de un `Value` por path con `.` como separador.
433/// Local porque sólo lo usa la lista renderer y no tiene tests
434/// dedicados afuera. Si crece su uso se puede mover a meta-runtime.
435fn lookup_field<'a>(v: &'a Value, path: &str) -> Option<&'a Value> {
436 let mut cur = v;
437 for seg in path.split('.') {
438 cur = cur.get(seg)?;
439 }
440 Some(cur)
441}
442
443impl<B: MetaBackend> Render for MetaApp<B> {
444 fn render(&mut self, _w: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
445 // Paleta del chrome viene del Theme global. Derivamos los
446 // 7 slots que las funciones render_* usan; las firmas
447 // siguen tomando los colors individuales (Hsla / Background)
448 // para preservar el shape del API interno.
449 //
450 // Si el caller no instaló un Theme, `Theme::global` panicea.
451 // Convención: el binario shell instala el theme en main.
452 let theme = Theme::global(cx).clone();
453 let bg = theme.bg_app.clone();
454 let panel = theme.bg_panel.clone();
455 let border = theme.border;
456 let text = theme.fg_text;
457 let text_dim = theme.fg_muted;
458 let accent = theme.accent;
459 let accent_active = theme.accent_strong;
460
461 let sidebar =
462 self.render_sidebar(cx, panel.clone(), border, text, text_dim, accent_active);
463 let main_panel = self.render_main(cx, panel, border, text, text_dim, accent);
464 let confirm_banner = self.render_confirm_delete_banner(cx);
465 let toast_div = self
466 .toast
467 .as_ref()
468 .map(|t| banner_themed(cx, Banner::Success, t.clone()));
469 let error_banner = self
470 .load_error
471 .as_ref()
472 .map(|e| banner_themed(cx, Banner::Error, e.clone()));
473
474 div()
475 .flex()
476 .flex_col()
477 .size_full()
478 .bg(bg)
479 // Capture phase: el Esc llega al root ANTES que cualquier
480 // TextInput descendiente. Si hay un delete pendiente, lo
481 // cancelamos. Sin pending no hacemos nada (el evento sigue
482 // su flujo normal y el TextInput recibe el Esc bubble).
483 .capture_key_down(cx.listener(|this, event: &KeyDownEvent, _w, cx| {
484 if event.keystroke.key != "escape" {
485 return;
486 }
487 if let Some((entity, _id)) = this.pending_delete.take() {
488 this.toast = Some(SharedString::from(format!(
489 "delete cancelado ({entity}) [esc]"
490 )));
491 cx.notify();
492 }
493 }))
494 .when_some(error_banner, |d, b| d.child(b))
495 .when_some(confirm_banner, |d, b| d.child(b))
496 .child(
497 div()
498 .flex()
499 .flex_row()
500 .flex_grow()
501 .child(sidebar)
502 .child(main_panel),
503 )
504 .when_some(toast_div, |d, t| d.child(t))
505 }
506}
507
508impl<B: MetaBackend> MetaApp<B> {
509 /// Renderea el banner modal de confirmación cuando hay un delete
510 /// pendiente. Devuelve `None` si no hay nada que confirmar.
511 ///
512 /// UX: banner amber prominente arriba de todo el contenido. No es
513 /// un overlay flotante (GPUI no expone z-index fácilmente sin
514 /// setup) — es un row del flex_col raíz, así que el contenido
515 /// debajo se desplaza unos pixels mientras está activo. Suficiente
516 /// para forzar al usuario a leer + click.
517 fn render_confirm_delete_banner(&self, cx: &mut Context<Self>) -> Option<gpui::Div> {
518 let (entity, id) = self.pending_delete.as_ref()?;
519 let entity_owned = entity.clone();
520 let id_owned = *id;
521 let entity_for_cancel = entity_owned.clone();
522 let id_short = short_uuid(&id_owned);
523 let entity_for_confirm = entity_owned.clone();
524
525 // Banner base usa los colors themed del kind Warning (amber
526 // que sigue is_dark del theme actual). Los buttons confirm
527 // (rojo) y cancel (gris) usan los colors themed del Error
528 // y un derivative del border respectivamente.
529 let theme = Theme::global(cx);
530 let (banner_bg, banner_text) = themed_colors(Banner::Warning, theme);
531 let (confirm_bg, confirm_text) = themed_colors(Banner::Error, theme);
532 let cancel_bg: gpui::Background = theme.bg_panel_alt.clone();
533 let cancel_text = theme.fg_text;
534 // Hover colors capturados antes de las closures para que el
535 // move |d| d.bg(...) los cierre.
536 let cancel_hover = theme.bg_button_hover();
537 let confirm_hover = theme.bg_destructive_hover();
538 let hint_color = theme.fg_muted;
539
540 Some(
541 div()
542 .flex()
543 .flex_row()
544 .items_center()
545 .gap(px(12.))
546 .px(px(12.))
547 .py(px(8.))
548 .bg(banner_bg)
549 .text_color(banner_text)
550 .text_size(px(12.))
551 .child(
552 div()
553 .flex_grow()
554 .flex()
555 .flex_col()
556 .child(format!("¿Borrar {entity_owned} {id_short}?"))
557 .child(
558 div()
559 .text_size(px(10.))
560 .text_color(hint_color)
561 .child("Esc para cancelar · click [Confirmar] para borrar"),
562 ),
563 )
564 .child(
565 div()
566 .id(SharedString::from(format!(
567 "confirm-del-cancel-{}",
568 id_owned
569 )))
570 .px(px(10.))
571 .py(px(4.))
572 .bg(cancel_bg)
573 .text_color(cancel_text)
574 .hover(move |d| d.bg(cancel_hover))
575 .child("Cancelar")
576 .on_click(cx.listener(move |this, _e: &ClickEvent, _w, cx| {
577 this.pending_delete = None;
578 this.toast = Some(SharedString::from(format!(
579 "delete cancelado ({entity_for_cancel})"
580 )));
581 cx.notify();
582 })),
583 )
584 .child(
585 div()
586 .id(SharedString::from(format!(
587 "confirm-del-ok-{}",
588 id_owned
589 )))
590 .px(px(10.))
591 .py(px(4.))
592 .bg(confirm_bg)
593 .text_color(confirm_text)
594 .hover(move |d| d.bg(confirm_hover))
595 .child("Confirmar")
596 .on_click(cx.listener(move |this, _e: &ClickEvent, _w, cx| {
597 // Limpiar primero para que un fallo del
598 // commit no deje el banner colgado y el
599 // toast tape el banner.
600 this.pending_delete = None;
601 match this.commit_delete(&entity_for_confirm, id_owned) {
602 Ok(outcome) => {
603 let base = format!(
604 "borrado {entity_for_confirm} {}",
605 short_uuid(&id_owned)
606 );
607 this.toast = Some(append_compact_msg(
608 base,
609 outcome.post_status,
610 ));
611 }
612 Err(e) => {
613 this.toast = Some(SharedString::from(format!(
614 "error borrando: {e}"
615 )));
616 }
617 }
618 cx.notify();
619 })),
620 ),
621 )
622 }
623
624 fn render_sidebar(
625 &self,
626 cx: &mut Context<Self>,
627 panel: gpui::Background,
628 border: gpui::Hsla,
629 text: gpui::Hsla,
630 text_dim: gpui::Hsla,
631 accent_active: gpui::Hsla,
632 ) -> gpui::Div {
633 // Slots ornament del theme para los menu items de abajo.
634 let theme = Theme::global(cx);
635 let menu_active_bg = theme.bg_row_active;
636 let menu_hover_bg = theme.bg_row_hover;
637 let mut sidebar = div()
638 .w(px(240.))
639 .h_full()
640 .bg(panel)
641 .border_r_1()
642 .border_color(border)
643 .flex()
644 .flex_col();
645
646 // Sidebar header: título + theme switcher en una row.
647 sidebar = sidebar.child(
648 div()
649 .flex()
650 .flex_row()
651 .items_center()
652 .px(px(12.))
653 .py(px(10.))
654 .text_color(text)
655 .text_size(px(13.))
656 .child(div().flex_grow().child("Nakui"))
657 .child(theme_switcher(cx)),
658 );
659
660 if self.modules.is_empty() {
661 return sidebar.child(
662 div()
663 .px(px(12.))
664 .py(px(8.))
665 .text_color(text_dim)
666 .text_size(px(11.))
667 .child("(no hay módulos cargados)"),
668 );
669 }
670
671 // Snapshot del active para evitar borrow del self adentro de la closure.
672 let active_snapshot = self.active.clone();
673
674 for (mod_idx, m) in self.modules.iter().enumerate() {
675 sidebar = sidebar.child(
676 div()
677 .px(px(12.))
678 .py(px(8.))
679 .border_t_1()
680 .border_color(border)
681 .text_color(text)
682 .text_size(px(12.))
683 .child(m.label.clone()),
684 );
685
686 for item in &m.menu {
687 let is_active = active_snapshot
688 .as_ref()
689 .map(|(i, v)| *i == mod_idx && v == &item.view)
690 .unwrap_or(false);
691 let label = item
692 .icon
693 .as_deref()
694 .map(|ic| format!("{ic} {}", item.label))
695 .unwrap_or_else(|| item.label.clone());
696
697 let view_key = item.view.clone();
698 sidebar = sidebar.child(
699 div()
700 .id(SharedString::from(format!(
701 "menu-{}-{}",
702 mod_idx, item.view
703 )))
704 .px(px(20.))
705 .py(px(6.))
706 .text_size(px(12.))
707 .text_color(if is_active { accent_active } else { text_dim })
708 .when(is_active, move |d| d.bg(menu_active_bg).text_color(text))
709 .hover(move |d| d.bg(menu_hover_bg))
710 .child(label)
711 .on_click(cx.listener(move |this, _e: &ClickEvent, _w, cx| {
712 this.select_view(mod_idx, view_key.clone(), cx);
713 })),
714 );
715 }
716 }
717 sidebar
718 }
719
720 fn render_main(
721 &mut self,
722 cx: &mut Context<Self>,
723 panel: gpui::Background,
724 border: gpui::Hsla,
725 text: gpui::Hsla,
726 text_dim: gpui::Hsla,
727 accent: gpui::Hsla,
728 ) -> gpui::Div {
729 let main = div()
730 .flex_grow()
731 .h_full()
732 .bg(panel)
733 .flex()
734 .flex_col()
735 .p(px(16.));
736
737 let (mod_idx, view_key) = match &self.active {
738 Some(a) => (a.0, a.1.clone()),
739 None => {
740 return main.child(
741 div()
742 .text_color(text_dim)
743 .child("Seleccioná un menú a la izquierda."),
744 );
745 }
746 };
747
748 let view = match self
749 .modules
750 .get(mod_idx)
751 .and_then(|m| m.views.get(&view_key))
752 {
753 Some(v) => v.clone(),
754 None => {
755 return main.child(
756 div()
757 .text_color(text_dim)
758 .child(format!("Vista no encontrada: {view_key}")),
759 );
760 }
761 };
762
763 match view {
764 View::List(lv) => self.render_list(cx, main, &lv, mod_idx, border, text, text_dim, accent),
765 View::Form(fv) => self.render_form(cx, main, &fv, mod_idx, border, text, text_dim, accent),
766 }
767 }
768
769 #[allow(clippy::too_many_arguments)]
770 fn render_list(
771 &self,
772 cx: &mut Context<Self>,
773 mut main: gpui::Div,
774 lv: &ListView,
775 mod_idx: usize,
776 border: gpui::Hsla,
777 text: gpui::Hsla,
778 text_dim: gpui::Hsla,
779 accent: gpui::Hsla,
780 ) -> gpui::Div {
781 // Ornament secundarios del theme para hovers, row separators,
782 // botones inline (edit ✎, delete ✕).
783 let theme = Theme::global(cx);
784 let row_separator = theme.bg_row_active;
785 let action_bg = theme.bg_button();
786 let action_hover = theme.bg_button_hover();
787 let destructive_fg = theme.accent_destructive();
788 let destructive_hover = theme.bg_destructive_hover();
789 let mut header = div()
790 .flex()
791 .flex_row()
792 .items_center()
793 .gap(px(12.))
794 .mb(px(12.))
795 .child(
796 div()
797 .text_color(text)
798 .text_size(px(18.))
799 .child(lv.title.clone()),
800 );
801 for (idx, action) in lv.actions.iter().enumerate() {
802 let label = match action {
803 Action::OpenView { label, view } => {
804 label.clone().unwrap_or_else(|| format!("→ {view}"))
805 }
806 Action::SeedEntity { entity, .. } => format!("Seed {entity}"),
807 Action::Morphism { name, .. } => format!("⚡ {name}"),
808 };
809 let action_clone = action.clone();
810 header = header.child(
811 div()
812 .id(SharedString::from(format!(
813 "list-action-{mod_idx}-{idx}"
814 )))
815 .px(px(10.))
816 .py(px(4.))
817 .bg(action_bg)
818 .text_color(accent)
819 .text_size(px(11.))
820 .rounded(px(3.))
821 .hover(move |d| d.bg(action_hover))
822 .child(label)
823 .on_click(cx.listener(move |this, _e: &ClickEvent, _w, cx| {
824 this.apply_action(action_clone.clone(), cx);
825 })),
826 );
827 }
828 main = main.child(header);
829
830 let rows = self.list_rows(&lv.entity);
831 let total = rows.len();
832
833 let total_weight: f32 = lv.columns.iter().map(|c| c.weight).sum::<f32>().max(0.01);
834 let mut col_header = div()
835 .flex()
836 .flex_row()
837 .py(px(6.))
838 .border_b_1()
839 .border_color(border)
840 .text_color(text_dim)
841 .text_size(px(11.));
842 for c in &lv.columns {
843 let frac = c.weight / total_weight;
844 col_header = col_header.child(
845 div()
846 .flex_grow()
847 .flex_basis(px(100. * frac))
848 .child(c.label.clone()),
849 );
850 }
851 col_header = col_header
852 .child(div().w(px(80.)).text_color(text_dim).child("id"))
853 .child(div().w(px(70.)).text_color(text_dim).child("acciones"));
854 main = main.child(col_header);
855
856 let entity_name = lv.entity.clone();
857 for (id, value) in &rows {
858 let id_copy = *id;
859 let entity_for_edit = entity_name.clone();
860 let entity_for_delete = entity_name.clone();
861 let mut row = div()
862 .flex()
863 .flex_row()
864 .py(px(6.))
865 .border_b_1()
866 .border_color(row_separator)
867 .text_color(text)
868 .text_size(px(12.));
869 for c in &lv.columns {
870 let frac = c.weight / total_weight;
871 let v = lookup_field(value, &c.field);
872 row = row.child(
873 div()
874 .flex_grow()
875 .flex_basis(px(100. * frac))
876 .child(render_value(v)),
877 );
878 }
879 row = row.child(
880 div()
881 .w(px(80.))
882 .text_color(text_dim)
883 .text_size(px(11.))
884 .child(short_uuid(id)),
885 );
886 // Acciones: ✎ edit + ✕ delete por fila.
887 row = row.child(
888 div()
889 .w(px(70.))
890 .flex()
891 .flex_row()
892 .gap(px(4.))
893 .child(
894 div()
895 .id(SharedString::from(format!(
896 "row-edit-{mod_idx}-{id_copy}"
897 )))
898 .px(px(6.))
899 .text_color(accent)
900 .text_size(px(13.))
901 .hover(move |d| d.bg(action_hover))
902 .child("✎")
903 .on_click(cx.listener(move |this, _e: &ClickEvent, _w, cx| {
904 this.open_edit(mod_idx, entity_for_edit.clone(), id_copy, cx);
905 })),
906 )
907 .child(
908 div()
909 .id(SharedString::from(format!(
910 "row-del-{mod_idx}-{id_copy}"
911 )))
912 .px(px(6.))
913 .text_color(destructive_fg)
914 .text_size(px(13.))
915 .hover(move |d| d.bg(destructive_hover))
916 .child("✕")
917 .on_click(cx.listener(move |this, _e: &ClickEvent, _w, cx| {
918 // Marca para borrar en lugar de borrar
919 // directo: el modal de confirmación se
920 // renderea arriba en `render` y maneja
921 // confirm/cancel.
922 this.pending_delete =
923 Some((entity_for_delete.clone(), id_copy));
924 this.toast = None;
925 cx.notify();
926 })),
927 ),
928 );
929 main = main.child(row);
930 }
931
932 if rows.is_empty() {
933 main = main.child(
934 div()
935 .py(px(12.))
936 .text_color(text_dim)
937 .text_size(px(12.))
938 .child(format!("(sin {})", lv.entity)),
939 );
940 } else {
941 main = main.child(
942 div()
943 .mt(px(8.))
944 .text_color(text_dim)
945 .text_size(px(11.))
946 .child(format!("{total} fila(s)")),
947 );
948 }
949
950 main
951 }
952
953 /// Renderea el selector clickable de records existentes para un
954 /// FieldSpec con kind=EntityRef. Lista compacta debajo del input;
955 /// click en una opción setea el TextInput del field con el UUID
956 /// seleccionado. El item del UUID actualmente seleccionado (si
957 /// hay) se resalta con accent color.
958 #[allow(clippy::too_many_arguments)]
959 fn render_entity_ref_selector(
960 &self,
961 cx: &mut Context<Self>,
962 field_name: String,
963 target_entity: String,
964 text: gpui::Hsla,
965 text_dim: gpui::Hsla,
966 accent: gpui::Hsla,
967 ) -> gpui::Div {
968 let _ = text;
969 // Slots ornament para hover/selected del selector + border.
970 let theme = Theme::global(cx);
971 let row_active = theme.bg_row_active;
972 let row_hover = theme.bg_row_hover;
973 let rows = self.list_rows(&target_entity);
974 let current = self
975 .form_inputs
976 .get(&field_name)
977 .map(|inp| inp.read(&*cx).text().to_string())
978 .unwrap_or_default();
979
980 let mut container = div()
981 .mt(px(4.))
982 .pl(px(8.))
983 .border_l_2()
984 .border_color(theme.border)
985 .flex()
986 .flex_col()
987 .gap(px(2.));
988
989 if rows.is_empty() {
990 return container.child(
991 div()
992 .px(px(6.))
993 .py(px(4.))
994 .text_color(text_dim)
995 .text_size(px(10.))
996 .child(format!(
997 "(sin {target_entity}: creá uno antes para referenciar)"
998 )),
999 );
1000 }
1001
1002 container = container.child(
1003 div()
1004 .text_color(text_dim)
1005 .text_size(px(10.))
1006 .child(format!("Seleccioná un {target_entity}:")),
1007 );
1008
1009 for (id, value) in &rows {
1010 let label = human_label_for_record(value, id);
1011 let id_str = id.to_string();
1012 let is_selected = current == id_str;
1013 let field_for_click = field_name.clone();
1014 let id_for_click = id_str.clone();
1015 container = container.child(
1016 div()
1017 .id(SharedString::from(format!(
1018 "entity-ref-{field_name}-{id_str}"
1019 )))
1020 .px(px(6.))
1021 .py(px(2.))
1022 .text_size(px(11.))
1023 .text_color(if is_selected { accent } else { text_dim })
1024 .when(is_selected, move |d| d.bg(row_active))
1025 .hover(move |d| d.bg(row_hover))
1026 .child(label)
1027 .on_click(cx.listener(move |this, _e: &ClickEvent, _w, cx| {
1028 if let Some(input) = this.form_inputs.get(&field_for_click) {
1029 input.update(cx, |inp, cx| inp.set_text(id_for_click.clone(), cx));
1030 }
1031 cx.notify();
1032 })),
1033 );
1034 }
1035 container
1036 }
1037
1038 #[allow(clippy::too_many_arguments)]
1039 fn render_form(
1040 &self,
1041 cx: &mut Context<Self>,
1042 mut main: gpui::Div,
1043 fv: &FormView,
1044 mod_idx: usize,
1045 _border: gpui::Hsla,
1046 text: gpui::Hsla,
1047 text_dim: gpui::Hsla,
1048 accent: gpui::Hsla,
1049 ) -> gpui::Div {
1050 // Slots ornament para el botón submit + bg de fallback inputs.
1051 let theme = Theme::global(cx);
1052 let submit_bg = theme.bg_button();
1053 let submit_hover = theme.bg_button_hover();
1054 let input_bg = theme.bg_input();
1055 // En modo edit, el título refleja eso para que el user no
1056 // se confunda creyendo que hace alta nueva.
1057 let title = match self.editing.as_ref() {
1058 Some((e, id)) if e == &fv.entity => {
1059 format!("Editar {} {}", fv.entity, short_uuid(id))
1060 }
1061 _ => fv.title.clone(),
1062 };
1063 main = main.child(
1064 div()
1065 .text_color(text)
1066 .text_size(px(18.))
1067 .mb(px(12.))
1068 .child(title),
1069 );
1070 for f in &fv.fields {
1071 let label = if f.required {
1072 format!("{} *", f.label)
1073 } else {
1074 f.label.clone()
1075 };
1076
1077 let mut field_box = div().flex().flex_col().mb(px(10.)).child(
1078 div()
1079 .text_color(text_dim)
1080 .text_size(px(11.))
1081 .mb(px(2.))
1082 .child(label),
1083 );
1084
1085 // Mount del TextInput vivo (creado en select_view).
1086 if let Some(input) = self.form_inputs.get(&f.name) {
1087 field_box = field_box.child(input.clone());
1088 } else {
1089 // No debería pasar — select_view crea inputs por cada
1090 // field. Fallback display estático por seguridad.
1091 field_box = field_box.child(
1092 div()
1093 .px(px(8.))
1094 .py(px(6.))
1095 .bg(input_bg)
1096 .text_color(text_dim)
1097 .child("(input no inicializado)"),
1098 );
1099 }
1100
1101 // Para EntityRef, agregamos un selector clickable de
1102 // records existentes debajo del TextInput. Click en una
1103 // opción setea el TextInput interno con el UUID; el
1104 // submit lee de ahí como cualquier otro field.
1105 if f.kind == FieldKind::EntityRef {
1106 if let Some(target_entity) = &f.ref_entity {
1107 field_box = field_box.child(self.render_entity_ref_selector(
1108 cx,
1109 f.name.clone(),
1110 target_entity.clone(),
1111 text,
1112 text_dim,
1113 accent,
1114 ));
1115 }
1116 }
1117
1118 if let Some(help) = &f.help {
1119 field_box = field_box.child(
1120 div()
1121 .mt(px(2.))
1122 .text_color(text_dim)
1123 .text_size(px(10.))
1124 .child(help.clone()),
1125 );
1126 }
1127 main = main.child(field_box);
1128 }
1129
1130 let editing_this = matches!(
1131 self.editing.as_ref(),
1132 Some((e, _)) if e == &fv.entity
1133 );
1134 let submit_label = match &fv.on_submit {
1135 Action::SeedEntity { entity, .. } => {
1136 if editing_this {
1137 format!("Guardar cambios en {entity}")
1138 } else {
1139 format!("Crear {entity}")
1140 }
1141 }
1142 Action::Morphism { name, .. } => format!("Ejecutar {name}"),
1143 Action::OpenView { label, view } => {
1144 label.clone().unwrap_or_else(|| format!("Ir a {view}"))
1145 }
1146 };
1147 let submit_action = fv.on_submit.clone();
1148 main = main.child(
1149 div().mt(px(12.)).child(
1150 div()
1151 .id(SharedString::from(format!("form-submit-{mod_idx}")))
1152 .px(px(12.))
1153 .py(px(6.))
1154 .bg(submit_bg)
1155 .text_color(accent)
1156 .text_size(px(12.))
1157 .rounded(px(3.))
1158 .hover(move |d| d.bg(submit_hover))
1159 .child(submit_label)
1160 .on_click(cx.listener(move |this, _e: &ClickEvent, _w, cx| {
1161 this.apply_action(submit_action.clone(), cx);
1162 })),
1163 ),
1164 );
1165
1166 main = main.child(
1167 div()
1168 .mt(px(20.))
1169 .text_color(text_dim)
1170 .text_size(px(10.))
1171 .child(
1172 "Tip: click en el campo para enfocar; Enter no envía (todavía), \
1173 usá el botón. Backspace borra el último carácter.",
1174 ),
1175 );
1176 main
1177 }
1178}
1179
1180#[cfg(test)]
1181mod tests {
1182 //! Tests del widget. Funciones puras (sin GPUI cx) sólo. Los
1183 //! tests de backend impl viven en el binario que provee el
1184 //! backend (ej: nakui-ui). Los helpers movidos a
1185 //! yahweh-meta-runtime se testean allí.
1186 use super::*;
1187 use serde_json::json;
1188
1189 #[test]
1190 fn lookup_field_simple_and_nested() {
1191 let v = json!({
1192 "name": "Acme",
1193 "address": { "city": "Bogotá", "country": "CO" }
1194 });
1195 assert_eq!(lookup_field(&v, "name").unwrap(), &json!("Acme"));
1196 assert_eq!(lookup_field(&v, "address.city").unwrap(), &json!("Bogotá"));
1197 assert!(lookup_field(&v, "missing").is_none());
1198 assert!(lookup_field(&v, "address.zipcode").is_none());
1199 }
1200
1201 #[test]
1202 fn append_compact_msg_handles_both_branches() {
1203 // Sin compact: base solo, sin separador.
1204 let s = append_compact_msg("creado X".into(), None);
1205 assert_eq!(s.as_ref(), "creado X");
1206 // Con compact: concatena con "; ".
1207 let s = append_compact_msg(
1208 "creado X".into(),
1209 Some("auto-compact: snapshot @ seq 49".into()),
1210 );
1211 assert_eq!(s.as_ref(), "creado X; auto-compact: snapshot @ seq 49");
1212 }
1213
1214 #[test]
1215 fn format_seed_toast_distinguishes_create_update_no_change() {
1216 let id = Uuid::new_v4();
1217 let outcome_create = WriteOutcome {
1218 id: Some(id),
1219 changed: 1,
1220 post_status: None,
1221 };
1222 let toast = format_seed_toast("Customer", false, &outcome_create);
1223 assert!(toast.starts_with("creado Customer"));
1224
1225 let outcome_update = WriteOutcome {
1226 id: Some(id),
1227 changed: 3,
1228 post_status: None,
1229 };
1230 let toast = format_seed_toast("Customer", true, &outcome_update);
1231 assert!(toast.starts_with("actualizado Customer"));
1232 assert!(toast.contains("(3 campo(s))"));
1233
1234 let outcome_no_change = WriteOutcome::no_change(id);
1235 let toast = format_seed_toast("Customer", true, &outcome_no_change);
1236 assert!(toast.contains("sin cambios"));
1237 }
1238}