gioser

tawasuyu/brahman

Rust 570TOML 215Markdown 68JSON 198261 Sutichaykuna

⎇ 6588d0ed6c355a57194a34a488c1ff2b3d8eb6b3 crates/modules/nahual/widgets/meta-form/src/lib.rs Rust · 1606 siq'ikuna · 61.6 KB Ñawpa willay Pi imata llamkarqan ch'uya

feat(nakui): Fase 3 del ERP — ficha de detalle 6588d0ed6 · sergio ·

1//! `nahual-widget-meta-form` — widget GPUI de metainterfaz.
2//!
3//! Renderea cualquier conjunto de [`nahual_meta_schema::Module`] sobre
4//! cualquier impl de [`nahual_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 nahual_meta_runtime::{
30    compute_clear_fields, compute_field_delta, format_value, human_label_for_record,
31    parse_field_value, render_value, resolve_param_value, short_uuid, validate_entity_refs,
32    value_to_input_text, MetaBackend, WriteOutcome,
33};
34use nahual_meta_schema::{
35    Action, Column, DetailView, FieldKind, FieldSpec, FormView, ListView, Module, RelatedList,
36    SelectOption, View,
37};
38use nahual_theme::Theme;
39use nahual_widget_banner::{banner_themed, themed_colors, Banner};
40use nahual_widget_text_input::TextInput;
41use nahual_widget_theme_switcher::theme_switcher;
42use serde_json::Value;
43use uuid::Uuid;
44
45/// Estado del runtime de UI. Toda la persistencia/ejecución está
46/// detrás del trait [`MetaBackend`]; este struct sólo conoce GPUI
47/// state y el schema de los módulos.
48///
49/// Genérico sobre el backend `B`. Un binario decide qué backend
50/// proveer (Nakui via `NakuiBackend`, mock para tests, etc.).
51pub struct MetaApp<B: MetaBackend> {
52    /// Módulos cargados, ordenados por id.
53    modules: Vec<Module>,
54    /// Backend que ejecuta seed/update/delete/morphism.
55    backend: B,
56    /// (módulo idx, vista key) actualmente activos.
57    active: Option<(usize, String)>,
58    /// Inputs vivos para el form actual: nombre del campo → TextInput.
59    /// Se reemplaza al cambiar de vista (drop de los anteriores).
60    form_inputs: BTreeMap<String, Entity<TextInput>>,
61    /// Si está set, el próximo render del Form pre-llena los inputs
62    /// con los valores del record indicado, y `commit_seed` emite
63    /// un `update` (no un seed nuevo). Limpia al cambiar de view o
64    /// tras submit exitoso.
65    editing: Option<(String, Uuid)>,
66    /// Si está set, el banner modal de confirmación de delete está
67    /// activo: `(entity, id)` del record que el usuario marcó para
68    /// borrar. Permanece hasta que el usuario click [Confirmar]
69    /// (ejecuta `commit_delete` y limpia) o [Cancelar] (sólo limpia).
70    /// Navegación a otra view también cancela.
71    pending_delete: Option<(String, Uuid)>,
72    /// Si la vista activa es una `Detail`, el id del record que se
73    /// muestra. Lo setea [`Self::select_detail`].
74    detail_target: Option<Uuid>,
75    /// Key de la vista a la que vuelve el botón «← Volver» de una
76    /// ficha — la lista desde la que se abrió.
77    detail_return: Option<String>,
78    /// Mensaje toast al pie (success de submit, error de carga, etc.).
79    toast: Option<SharedString>,
80    /// Si la carga de módulos falló al inicio.
81    load_error: Option<SharedString>,
82}
83
84impl<B: MetaBackend> MetaApp<B> {
85    /// Constructor del widget. El caller pre-construye sus módulos +
86    /// backend + cualquier mensaje inicial que quiera mostrar al
87    /// usuario al abrir (típicamente el toast del replay y el error
88    /// banner de cosas que fallaron de bootstrap).
89    ///
90    /// La active view default es la primera entry del menú del
91    /// primer módulo (orden lexicográfico por id).
92    pub fn new(
93        modules: Vec<Module>,
94        backend: B,
95        initial_toast: Option<String>,
96        initial_error: Option<String>,
97        _cx: &mut Context<Self>,
98    ) -> Self {
99        let active = modules
100            .first()
101            .and_then(|m| m.menu.first().map(|item| (0usize, item.view.clone())));
102        Self {
103            modules,
104            backend,
105            active,
106            form_inputs: BTreeMap::new(),
107            editing: None,
108            pending_delete: None,
109            detail_target: None,
110            detail_return: None,
111            toast: initial_toast.map(SharedString::from),
112            load_error: initial_error.map(SharedString::from),
113        }
114    }
115
116    /// Cambia la vista activa. Si la nueva vista es un Form, crea
117    /// `TextInput` entities para cada field. Pre-llena con valores
118    /// del record si hay `editing` para esa entity; si no, usa el
119    /// `default` del schema.
120    /// Drop de los inputs anteriores ocurre al sobreescribir el map.
121    fn select_view(&mut self, mod_idx: usize, view_key: String, cx: &mut Context<Self>) {
122        self.active = Some((mod_idx, view_key.clone()));
123        self.toast = None;
124        // Navegar a otra view cancela cualquier delete pendiente:
125        // el record marcado puede no estar visible en la nueva view.
126        self.pending_delete = None;
127        self.detail_target = None;
128        self.form_inputs = BTreeMap::new();
129        if let Some(module) = self.modules.get(mod_idx) {
130            if let Some(View::Form(form)) = module.views.get(&view_key) {
131                // Snapshot del record si estamos editando esta entity.
132                let editing_record: Option<Value> = self.editing.as_ref().and_then(|(e, id)| {
133                    if e == &form.entity {
134                        self.backend.load_record(e, *id)
135                    } else {
136                        None
137                    }
138                });
139                for f in &form.fields {
140                    let initial = if f.kind == FieldKind::AutoId {
141                        // Editando: conservar el id del record.
142                        // Alta: UUID nuevo, que el usuario no teclea.
143                        editing_record
144                            .as_ref()
145                            .and_then(|rec| rec.get(&f.name).map(value_to_input_text))
146                            .unwrap_or_else(|| Uuid::new_v4().to_string())
147                    } else if let Some(rec) = &editing_record {
148                        rec.get(&f.name)
149                            .map(value_to_input_text)
150                            .unwrap_or_else(|| f.default.clone().unwrap_or_default())
151                    } else {
152                        f.default.clone().unwrap_or_default()
153                    };
154                    let input = cx.new(|cx| TextInput::new(initial, cx));
155                    self.form_inputs.insert(f.name.clone(), input);
156                }
157            } else {
158                // Cambiar a una view que no es Form invalida el editing
159                // pendiente.
160                self.editing = None;
161            }
162        }
163        cx.notify();
164    }
165
166    /// Abre la ficha de detalle `detail_view` para el record `id`.
167    /// Recuerda la vista actual para el botón «← Volver».
168    fn select_detail(
169        &mut self,
170        mod_idx: usize,
171        detail_view: String,
172        id: Uuid,
173        cx: &mut Context<Self>,
174    ) {
175        self.detail_return = self.active.as_ref().map(|(_, v)| v.clone());
176        self.active = Some((mod_idx, detail_view));
177        self.detail_target = Some(id);
178        self.editing = None;
179        self.pending_delete = None;
180        self.form_inputs = BTreeMap::new();
181        self.toast = None;
182        cx.notify();
183    }
184
185    /// Inicia un edit del record: setea `editing` y abre la primera
186    /// view de tipo Form del módulo (convención: la del schema).
187    fn open_edit(&mut self, mod_idx: usize, entity: String, id: Uuid, cx: &mut Context<Self>) {
188        self.editing = Some((entity.clone(), id));
189        let form_view_key = self.modules.get(mod_idx).and_then(|m| {
190            m.views.iter().find_map(|(key, v)| match v {
191                View::Form(form) if form.entity == entity => Some(key.clone()),
192                _ => None,
193            })
194        });
195        match form_view_key {
196            Some(key) => self.select_view(mod_idx, key, cx),
197            None => {
198                self.toast = Some(SharedString::from(format!(
199                    "no hay form view para entity '{entity}' en este módulo"
200                )));
201                self.editing = None;
202                cx.notify();
203            }
204        }
205    }
206
207    /// Borra un record. Emite Morphism con un FieldOp::Delete + lo
208    /// aplica al store via `apply` (no via remove directo, mantiene
209    /// el modelo de "todo cambio post-seed pasa por ops").
210    /// Borra un record vía `MetaBackend::delete`. Devuelve el outcome
211    /// del backend (incluye eventual `post_status` del compact tick).
212    fn commit_delete(&mut self, entity: &str, id: Uuid) -> Result<WriteOutcome, String> {
213        self.backend.delete(entity, id)
214    }
215
216    /// Aplica una acción (click en menú, botón de form, action de
217    /// list). Mutaciones contra el backend ocurren acá.
218    pub fn apply_action(&mut self, action: Action, cx: &mut Context<Self>) {
219        let mod_idx = match self.active.as_ref() {
220            Some((i, _)) => *i,
221            None => return,
222        };
223        match action {
224            Action::OpenView { view, .. } => {
225                // Salir a otra view cancela el edit pendiente.
226                self.editing = None;
227                self.select_view(mod_idx, view, cx);
228            }
229            Action::SeedEntity { entity, next_view } => {
230                let was_editing = self.editing.is_some();
231                match self.commit_seed(mod_idx, &entity, cx) {
232                    Ok(outcome) => {
233                        let toast_msg = format_seed_toast(&entity, was_editing, &outcome);
234                        self.toast = Some(append_compact_msg(toast_msg, outcome.post_status));
235                        // Limpia editing tras un commit exitoso —
236                        // el record ya está sincronizado (incluso
237                        // un NoChange cierra el modo edit).
238                        self.editing = None;
239                        if let Some(v) = next_view {
240                            self.select_view(mod_idx, v, cx);
241                        } else {
242                            for input in self.form_inputs.values() {
243                                input.update(cx, |inp, cx| inp.set_text("", cx));
244                            }
245                        }
246                    }
247                    Err(e) => {
248                        self.toast = Some(SharedString::from(format!("error: {e}")));
249                    }
250                }
251                cx.notify();
252            }
253            Action::Morphism {
254                name,
255                inputs,
256                params,
257                next_view,
258            } => {
259                match self.commit_morphism(mod_idx, &name, &inputs, &params, cx) {
260                    Ok(outcome) => {
261                        let base =
262                            format!("morphism '{name}' OK ({} op(s) aplicadas)", outcome.changed);
263                        self.toast = Some(append_compact_msg(base, outcome.post_status));
264                        if let Some(v) = next_view {
265                            self.select_view(mod_idx, v, cx);
266                        }
267                    }
268                    Err(e) => {
269                        self.toast =
270                            Some(SharedString::from(format!("morphism '{name}' falló: {e}")));
271                    }
272                }
273                cx.notify();
274            }
275        }
276    }
277
278    /// Despacha un morphism via el backend. Resuelve inputs (UUIDs)
279    /// + params (Value object) leyendo los TextInput del form.
280    fn commit_morphism(
281        &mut self,
282        mod_idx: usize,
283        morphism: &str,
284        inputs_map: &BTreeMap<String, String>,
285        params_fields: &[String],
286        cx: &mut Context<Self>,
287    ) -> Result<WriteOutcome, String> {
288        let module = self
289            .modules
290            .get(mod_idx)
291            .ok_or_else(|| "módulo inválido".to_string())?;
292        let module_id = module.id.clone();
293
294        // Resolver inputs: por cada (role, field_name), parsear el
295        // value del input como Uuid.
296        let mut inputs: BTreeMap<String, Uuid> = BTreeMap::new();
297        for (role, field_name) in inputs_map {
298            let raw = self
299                .form_inputs
300                .get(field_name)
301                .map(|inp| inp.read(&*cx).text().to_string())
302                .ok_or_else(|| format!("input field '{field_name}' no existe en el form"))?;
303            let id = Uuid::parse_str(raw.trim()).map_err(|_| {
304                format!("input '{role}' (field '{field_name}'): '{raw}' no es UUID válido")
305            })?;
306            inputs.insert(role.clone(), id);
307        }
308
309        // Resolver params: si la lista está vacía, todos los fields
310        // del form que no estén en `inputs_map` van a params.
311        let input_field_set: std::collections::BTreeSet<&String> = inputs_map.values().collect();
312        let mut params_obj = serde_json::Map::new();
313        let field_iter: Vec<String> = if params_fields.is_empty() {
314            self.form_inputs
315                .keys()
316                .filter(|k| !input_field_set.contains(*k))
317                .cloned()
318                .collect()
319        } else {
320            params_fields.to_vec()
321        };
322
323        // FieldSpec del Form view activo para parseo estricto por kind.
324        let active_form_fields: Option<Vec<FieldSpec>> =
325            self.active.as_ref().and_then(|(_, vk)| {
326                module.views.get(vk).and_then(|v| match v {
327                    View::Form(f) => Some(f.fields.clone()),
328                    _ => None,
329                })
330            });
331
332        for field_name in field_iter {
333            let raw = self
334                .form_inputs
335                .get(&field_name)
336                .map(|inp| inp.read(&*cx).text().to_string())
337                .unwrap_or_default();
338            let spec = active_form_fields
339                .as_ref()
340                .and_then(|fs| fs.iter().find(|f| f.name == field_name));
341            let value = resolve_param_value(&field_name, &raw, spec)?;
342            params_obj.insert(field_name, value);
343        }
344
345        self.backend
346            .morphism(&module_id, morphism, inputs, Value::Object(params_obj))
347    }
348
349    /// Construye un payload desde los TextInput vivos y delega al
350    /// backend (`seed` para alta nueva, `update` con set+clear para
351    /// edit). Devuelve el `WriteOutcome` del backend (incluye
352    /// `changed` y `post_status` para el toast).
353    fn commit_seed(
354        &mut self,
355        mod_idx: usize,
356        entity: &str,
357        cx: &mut Context<Self>,
358    ) -> Result<WriteOutcome, String> {
359        let module = &self.modules[mod_idx];
360        let spec_fields: Vec<FieldSpec> = match self.active.as_ref() {
361            Some((_, view_key)) => match module.views.get(view_key) {
362                Some(View::Form(f)) => f.fields.clone(),
363                _ => return Err("la vista activa no es un Form".into()),
364            },
365            None => return Err("ninguna vista activa".into()),
366        };
367        let mut obj = serde_json::Map::new();
368        // Fields que el form deja vacíos y son optional: candidatos
369        // a Clear en el path de EDIT.
370        let mut to_clear: Vec<String> = Vec::new();
371        // EntityRef refs a validar tras parse loop (en una toma del
372        // backend en lugar de N).
373        let mut entity_refs: Vec<(String, String, Uuid)> = Vec::new();
374        for f in &spec_fields {
375            let raw = self
376                .form_inputs
377                .get(&f.name)
378                .map(|input| input.read(cx).text().to_string())
379                .unwrap_or_default();
380            if f.required && raw.trim().is_empty() {
381                return Err(format!("campo '{}' es obligatorio", f.label));
382            }
383            if raw.is_empty() && !f.required {
384                to_clear.push(f.name.clone());
385                continue;
386            }
387            let value =
388                parse_field_value(f.kind, &raw).map_err(|e| format!("campo '{}': {e}", f.label))?;
389            if f.kind == FieldKind::EntityRef {
390                if let (Some(target), Some(uuid_str)) = (&f.ref_entity, value.as_str()) {
391                    let id = Uuid::parse_str(uuid_str).expect("parse_field_value validated UUID");
392                    entity_refs.push((f.label.clone(), target.clone(), id));
393                }
394            }
395            obj.insert(f.name.clone(), value);
396        }
397        // Validar EntityRefs contra el backend actual. Cierre wrappea
398        // backend.load_record para mantener la firma de
399        // validate_entity_refs (que es store-agnóstica).
400        if !entity_refs.is_empty() {
401            let backend = &self.backend;
402            validate_entity_refs(|e, id| backend.load_record(e, id), &entity_refs)?;
403        }
404
405        // Ramificación: edit (hay editing para esta entity) vs alta.
406        let editing_match = self.editing.as_ref().filter(|(e, _)| e == entity).cloned();
407
408        if let Some((_, id)) = editing_match {
409            // EDIT: cargar current, computar delta, llamar
410            // backend.update con set+clear pre-computados.
411            let current = self.backend.load_record(entity, id).unwrap_or(Value::Null);
412            let set_delta = compute_field_delta(&current, &obj);
413            let clear_fields = compute_clear_fields(&current, &to_clear);
414            self.backend.update(entity, id, set_delta, clear_fields)
415        } else {
416            // SEED: alta nueva — el backend genera el Uuid.
417            self.backend.seed(entity, obj)
418        }
419    }
420
421    /// Snapshot ordenado de records de una entity (proxy al backend).
422    fn list_rows(&self, entity: &str) -> Vec<(Uuid, Value)> {
423        self.backend.list_records(entity)
424    }
425}
426
427/// Formatea el toast para la rama Action::SeedEntity según el
428/// `WriteOutcome` del backend. `was_editing` distingue "creado"
429/// vs "actualizado" — el WriteOutcome solo no alcanza porque
430/// `seed` y `update` ambos devuelven `id = Some(...)`.
431fn format_seed_toast(entity: &str, was_editing: bool, outcome: &WriteOutcome) -> String {
432    let id_short = outcome.id.map(|id| short_uuid(&id)).unwrap_or_default();
433    match (was_editing, outcome.changed) {
434        (false, _) => format!("creado {entity} {id_short}"),
435        (true, 0) => format!("{entity} {id_short} sin cambios — no log entry"),
436        (true, n) => format!("actualizado {entity} {id_short} ({n} campo(s))"),
437    }
438}
439
440/// Concatena el msg opcional del compact tick al toast del op
441/// original con `"; "` separator.
442fn append_compact_msg(base: String, compact_msg: Option<String>) -> SharedString {
443    match compact_msg {
444        Some(m) => SharedString::from(format!("{base}; {m}")),
445        None => SharedString::from(base),
446    }
447}
448
449/// Walker dentro de un `Value` por path con `.` como separador.
450/// Local porque sólo lo usa la lista renderer y no tiene tests
451/// dedicados afuera. Si crece su uso se puede mover a meta-runtime.
452fn lookup_field<'a>(v: &'a Value, path: &str) -> Option<&'a Value> {
453    let mut cur = v;
454    for seg in path.split('.') {
455        cur = cur.get(seg)?;
456    }
457    Some(cur)
458}
459
460impl<B: MetaBackend> Render for MetaApp<B> {
461    fn render(&mut self, _w: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
462        // Paleta del chrome viene del Theme global. Derivamos los
463        // 7 slots que las funciones render_* usan; las firmas
464        // siguen tomando los colors individuales (Hsla / Background)
465        // para preservar el shape del API interno.
466        //
467        // Si el caller no instaló un Theme, `Theme::global` panicea.
468        // Convención: el binario shell instala el theme en main.
469        let theme = Theme::global(cx).clone();
470        let bg = theme.bg_app;
471        let panel = theme.bg_panel;
472        let border = theme.border;
473        let text = theme.fg_text;
474        let text_dim = theme.fg_muted;
475        let accent = theme.accent;
476        let accent_active = theme.accent_strong;
477
478        let sidebar = self.render_sidebar(cx, panel, border, text, text_dim, accent_active);
479        let main_panel = self.render_main(cx, panel, border, text, text_dim, accent);
480        let confirm_banner = self.render_confirm_delete_banner(cx);
481        let toast_div = self
482            .toast
483            .as_ref()
484            .map(|t| banner_themed(cx, Banner::Success, t.clone()));
485        let error_banner = self
486            .load_error
487            .as_ref()
488            .map(|e| banner_themed(cx, Banner::Error, e.clone()));
489
490        div()
491            .flex()
492            .flex_col()
493            .size_full()
494            .bg(bg)
495            // Capture phase: el Esc llega al root ANTES que cualquier
496            // TextInput descendiente. Si hay un delete pendiente, lo
497            // cancelamos. Sin pending no hacemos nada (el evento sigue
498            // su flujo normal y el TextInput recibe el Esc bubble).
499            .capture_key_down(cx.listener(|this, event: &KeyDownEvent, _w, cx| {
500                if event.keystroke.key != "escape" {
501                    return;
502                }
503                if let Some((entity, _id)) = this.pending_delete.take() {
504                    this.toast = Some(SharedString::from(format!(
505                        "delete cancelado ({entity}) [esc]"
506                    )));
507                    cx.notify();
508                }
509            }))
510            .when_some(error_banner, |d, b| d.child(b))
511            .when_some(confirm_banner, |d, b| d.child(b))
512            .child(
513                div()
514                    .flex()
515                    .flex_row()
516                    .flex_grow()
517                    .child(sidebar)
518                    .child(main_panel),
519            )
520            .when_some(toast_div, |d, t| d.child(t))
521    }
522}
523
524impl<B: MetaBackend> MetaApp<B> {
525    /// Renderea el banner modal de confirmación cuando hay un delete
526    /// pendiente. Devuelve `None` si no hay nada que confirmar.
527    ///
528    /// UX: banner amber prominente arriba de todo el contenido. No es
529    /// un overlay flotante (GPUI no expone z-index fácilmente sin
530    /// setup) — es un row del flex_col raíz, así que el contenido
531    /// debajo se desplaza unos pixels mientras está activo. Suficiente
532    /// para forzar al usuario a leer + click.
533    fn render_confirm_delete_banner(&self, cx: &mut Context<Self>) -> Option<gpui::Div> {
534        let (entity, id) = self.pending_delete.as_ref()?;
535        let entity_owned = entity.clone();
536        let id_owned = *id;
537        let entity_for_cancel = entity_owned.clone();
538        let id_short = short_uuid(&id_owned);
539        let entity_for_confirm = entity_owned.clone();
540
541        // Banner base usa los colors themed del kind Warning (amber
542        // que sigue is_dark del theme actual). Los buttons confirm
543        // (rojo) y cancel (gris) usan los colors themed del Error
544        // y un derivative del border respectivamente.
545        let theme = Theme::global(cx);
546        let (banner_bg, banner_text) = themed_colors(Banner::Warning, theme);
547        let (confirm_bg, confirm_text) = themed_colors(Banner::Error, theme);
548        let cancel_bg: gpui::Background = theme.bg_panel_alt;
549        let cancel_text = theme.fg_text;
550        // Hover colors capturados antes de las closures para que el
551        // move |d| d.bg(...) los cierre.
552        let cancel_hover = theme.bg_button_hover();
553        let confirm_hover = theme.bg_destructive_hover();
554        let hint_color = theme.fg_muted;
555
556        Some(
557            div()
558                .flex()
559                .flex_row()
560                .items_center()
561                .gap(px(12.))
562                .px(px(12.))
563                .py(px(8.))
564                .bg(banner_bg)
565                .text_color(banner_text)
566                .text_size(px(12.))
567                .child(
568                    div()
569                        .flex_grow()
570                        .flex()
571                        .flex_col()
572                        .child(format!("¿Borrar {entity_owned} {id_short}?"))
573                        .child(
574                            div()
575                                .text_size(px(10.))
576                                .text_color(hint_color)
577                                .child("Esc para cancelar · click [Confirmar] para borrar"),
578                        ),
579                )
580                .child(
581                    div()
582                        .id(SharedString::from(format!(
583                            "confirm-del-cancel-{}",
584                            id_owned
585                        )))
586                        .px(px(10.))
587                        .py(px(4.))
588                        .bg(cancel_bg)
589                        .text_color(cancel_text)
590                        .hover(move |d| d.bg(cancel_hover))
591                        .child("Cancelar")
592                        .on_click(cx.listener(move |this, _e: &ClickEvent, _w, cx| {
593                            this.pending_delete = None;
594                            this.toast = Some(SharedString::from(format!(
595                                "delete cancelado ({entity_for_cancel})"
596                            )));
597                            cx.notify();
598                        })),
599                )
600                .child(
601                    div()
602                        .id(SharedString::from(format!("confirm-del-ok-{}", id_owned)))
603                        .px(px(10.))
604                        .py(px(4.))
605                        .bg(confirm_bg)
606                        .text_color(confirm_text)
607                        .hover(move |d| d.bg(confirm_hover))
608                        .child("Confirmar")
609                        .on_click(cx.listener(move |this, _e: &ClickEvent, _w, cx| {
610                            // Limpiar primero para que un fallo del
611                            // commit no deje el banner colgado y el
612                            // toast tape el banner.
613                            this.pending_delete = None;
614                            match this.commit_delete(&entity_for_confirm, id_owned) {
615                                Ok(outcome) => {
616                                    let base = format!(
617                                        "borrado {entity_for_confirm} {}",
618                                        short_uuid(&id_owned)
619                                    );
620                                    this.toast =
621                                        Some(append_compact_msg(base, outcome.post_status));
622                                }
623                                Err(e) => {
624                                    this.toast =
625                                        Some(SharedString::from(format!("error borrando: {e}")));
626                                }
627                            }
628                            cx.notify();
629                        })),
630                ),
631        )
632    }
633
634    fn render_sidebar(
635        &self,
636        cx: &mut Context<Self>,
637        panel: gpui::Background,
638        border: gpui::Hsla,
639        text: gpui::Hsla,
640        text_dim: gpui::Hsla,
641        accent_active: gpui::Hsla,
642    ) -> gpui::Div {
643        // Slots ornament del theme para los menu items de abajo.
644        let theme = Theme::global(cx);
645        let menu_active_bg = theme.bg_row_active;
646        let menu_hover_bg = theme.bg_row_hover;
647        let mut sidebar = div()
648            .w(px(240.))
649            .h_full()
650            .bg(panel)
651            .border_r_1()
652            .border_color(border)
653            .flex()
654            .flex_col();
655
656        // Sidebar header: título + theme switcher en una row.
657        sidebar = sidebar.child(
658            div()
659                .flex()
660                .flex_row()
661                .items_center()
662                .px(px(12.))
663                .py(px(10.))
664                .text_color(text)
665                .text_size(px(13.))
666                .child(div().flex_grow().child("Nakui"))
667                .child(theme_switcher(cx)),
668        );
669
670        if self.modules.is_empty() {
671            return sidebar.child(
672                div()
673                    .px(px(12.))
674                    .py(px(8.))
675                    .text_color(text_dim)
676                    .text_size(px(11.))
677                    .child("(no hay módulos cargados)"),
678            );
679        }
680
681        // Snapshot del active para evitar borrow del self adentro de la closure.
682        let active_snapshot = self.active.clone();
683
684        for (mod_idx, m) in self.modules.iter().enumerate() {
685            sidebar = sidebar.child(
686                div()
687                    .px(px(12.))
688                    .py(px(8.))
689                    .border_t_1()
690                    .border_color(border)
691                    .text_color(text)
692                    .text_size(px(12.))
693                    .child(m.label.clone()),
694            );
695
696            for item in &m.menu {
697                let is_active = active_snapshot
698                    .as_ref()
699                    .map(|(i, v)| *i == mod_idx && v == &item.view)
700                    .unwrap_or(false);
701                let label = item
702                    .icon
703                    .as_deref()
704                    .map(|ic| format!("{ic}  {}", item.label))
705                    .unwrap_or_else(|| item.label.clone());
706
707                let view_key = item.view.clone();
708                sidebar = sidebar.child(
709                    div()
710                        .id(SharedString::from(format!(
711                            "menu-{}-{}",
712                            mod_idx, item.view
713                        )))
714                        .px(px(20.))
715                        .py(px(6.))
716                        .text_size(px(12.))
717                        .text_color(if is_active { accent_active } else { text_dim })
718                        .when(is_active, move |d| d.bg(menu_active_bg).text_color(text))
719                        .hover(move |d| d.bg(menu_hover_bg))
720                        .child(label)
721                        .on_click(cx.listener(move |this, _e: &ClickEvent, _w, cx| {
722                            this.select_view(mod_idx, view_key.clone(), cx);
723                        })),
724                );
725            }
726        }
727        sidebar
728    }
729
730    fn render_main(
731        &mut self,
732        cx: &mut Context<Self>,
733        panel: gpui::Background,
734        border: gpui::Hsla,
735        text: gpui::Hsla,
736        text_dim: gpui::Hsla,
737        accent: gpui::Hsla,
738    ) -> gpui::Div {
739        let main = div()
740            .flex_grow()
741            .h_full()
742            .bg(panel)
743            .flex()
744            .flex_col()
745            .p(px(16.));
746
747        let (mod_idx, view_key) = match &self.active {
748            Some(a) => (a.0, a.1.clone()),
749            None => {
750                return main.child(
751                    div()
752                        .text_color(text_dim)
753                        .child("Seleccioná un menú a la izquierda."),
754                );
755            }
756        };
757
758        let view = match self
759            .modules
760            .get(mod_idx)
761            .and_then(|m| m.views.get(&view_key))
762        {
763            Some(v) => v.clone(),
764            None => {
765                return main.child(
766                    div()
767                        .text_color(text_dim)
768                        .child(format!("Vista no encontrada: {view_key}")),
769                );
770            }
771        };
772
773        match view {
774            View::List(lv) => {
775                self.render_list(cx, main, &lv, mod_idx, border, text, text_dim, accent)
776            }
777            View::Form(fv) => {
778                self.render_form(cx, main, &fv, mod_idx, border, text, text_dim, accent)
779            }
780            View::Detail(dv) => {
781                self.render_detail(cx, main, &dv, mod_idx, border, text, text_dim, accent)
782            }
783        }
784    }
785
786    #[allow(clippy::too_many_arguments)]
787    fn render_list(
788        &self,
789        cx: &mut Context<Self>,
790        mut main: gpui::Div,
791        lv: &ListView,
792        mod_idx: usize,
793        border: gpui::Hsla,
794        text: gpui::Hsla,
795        text_dim: gpui::Hsla,
796        accent: gpui::Hsla,
797    ) -> gpui::Div {
798        // Ornament secundarios del theme para hovers, row separators,
799        // botones inline (edit ✎, delete ✕).
800        let theme = Theme::global(cx);
801        let row_separator = theme.bg_row_active;
802        let action_bg = theme.bg_button();
803        let action_hover = theme.bg_button_hover();
804        let destructive_fg = theme.accent_destructive();
805        let destructive_hover = theme.bg_destructive_hover();
806        let mut header = div()
807            .flex()
808            .flex_row()
809            .items_center()
810            .gap(px(12.))
811            .mb(px(12.))
812            .child(
813                div()
814                    .text_color(text)
815                    .text_size(px(18.))
816                    .child(lv.title.clone()),
817            );
818        for (idx, action) in lv.actions.iter().enumerate() {
819            let label = match action {
820                Action::OpenView { label, view } => {
821                    label.clone().unwrap_or_else(|| format!("→ {view}"))
822                }
823                Action::SeedEntity { entity, .. } => format!("Seed {entity}"),
824                Action::Morphism { name, .. } => format!("⚡ {name}"),
825            };
826            let action_clone = action.clone();
827            header = header.child(
828                div()
829                    .id(SharedString::from(format!("list-action-{mod_idx}-{idx}")))
830                    .px(px(10.))
831                    .py(px(4.))
832                    .bg(action_bg)
833                    .text_color(accent)
834                    .text_size(px(11.))
835                    .rounded(px(3.))
836                    .hover(move |d| d.bg(action_hover))
837                    .child(label)
838                    .on_click(cx.listener(move |this, _e: &ClickEvent, _w, cx| {
839                        this.apply_action(action_clone.clone(), cx);
840                    })),
841            );
842        }
843        main = main.child(header);
844
845        let rows = self.list_rows(&lv.entity);
846        let total = rows.len();
847        let row_detail = lv.row_detail.clone();
848
849        let total_weight: f32 = lv.columns.iter().map(|c| c.weight).sum::<f32>().max(0.01);
850        let mut col_header = div()
851            .flex()
852            .flex_row()
853            .py(px(6.))
854            .border_b_1()
855            .border_color(border)
856            .text_color(text_dim)
857            .text_size(px(11.));
858        for c in &lv.columns {
859            let frac = c.weight / total_weight;
860            col_header = col_header.child(
861                div()
862                    .flex_grow()
863                    .flex_basis(px(100. * frac))
864                    .child(c.label.clone()),
865            );
866        }
867        col_header = col_header
868            .child(div().w(px(80.)).text_color(text_dim).child("id"))
869            .child(div().w(px(100.)).text_color(text_dim).child("acciones"));
870        main = main.child(col_header);
871
872        let entity_name = lv.entity.clone();
873        for (id, value) in &rows {
874            let id_copy = *id;
875            let entity_for_edit = entity_name.clone();
876            let entity_for_delete = entity_name.clone();
877            let mut row = div()
878                .flex()
879                .flex_row()
880                .py(px(6.))
881                .border_b_1()
882                .border_color(row_separator)
883                .text_color(text)
884                .text_size(px(12.));
885            for c in &lv.columns {
886                let frac = c.weight / total_weight;
887                let v = lookup_field(value, &c.field);
888                row = row.child(
889                    div()
890                        .flex_grow()
891                        .flex_basis(px(100. * frac))
892                        .child(self.render_cell(c, v)),
893                );
894            }
895            row = row.child(
896                div()
897                    .w(px(80.))
898                    .text_color(text_dim)
899                    .text_size(px(11.))
900                    .child(short_uuid(id)),
901            );
902            // Acciones por fila: 👁 ver (si hay row_detail) + ✎ + ✕.
903            let mut actions = div().w(px(100.)).flex().flex_row().gap(px(4.));
904            if let Some(detail_view) = &row_detail {
905                let dv_key = detail_view.clone();
906                actions = actions.child(
907                    div()
908                        .id(SharedString::from(format!("row-view-{mod_idx}-{id_copy}")))
909                        .px(px(6.))
910                        .text_color(text_dim)
911                        .text_size(px(13.))
912                        .hover(move |d| d.bg(action_hover))
913                        .child("👁")
914                        .on_click(cx.listener(move |this, _e: &ClickEvent, _w, cx| {
915                            this.select_detail(mod_idx, dv_key.clone(), id_copy, cx);
916                        })),
917                );
918            }
919            row = row.child(
920                actions
921                    .child(
922                        div()
923                            .id(SharedString::from(format!("row-edit-{mod_idx}-{id_copy}")))
924                            .px(px(6.))
925                            .text_color(accent)
926                            .text_size(px(13.))
927                            .hover(move |d| d.bg(action_hover))
928                            .child("✎")
929                            .on_click(cx.listener(move |this, _e: &ClickEvent, _w, cx| {
930                                this.open_edit(mod_idx, entity_for_edit.clone(), id_copy, cx);
931                            })),
932                    )
933                    .child(
934                        div()
935                            .id(SharedString::from(format!("row-del-{mod_idx}-{id_copy}")))
936                            .px(px(6.))
937                            .text_color(destructive_fg)
938                            .text_size(px(13.))
939                            .hover(move |d| d.bg(destructive_hover))
940                            .child("✕")
941                            .on_click(cx.listener(move |this, _e: &ClickEvent, _w, cx| {
942                                // Marca para borrar en lugar de borrar
943                                // directo: el modal de confirmación se
944                                // renderea arriba en `render` y maneja
945                                // confirm/cancel.
946                                this.pending_delete = Some((entity_for_delete.clone(), id_copy));
947                                this.toast = None;
948                                cx.notify();
949                            })),
950                    ),
951            );
952            main = main.child(row);
953        }
954
955        if rows.is_empty() {
956            main = main.child(
957                div()
958                    .py(px(12.))
959                    .text_color(text_dim)
960                    .text_size(px(12.))
961                    .child(format!("(sin {})", lv.entity)),
962            );
963        } else {
964            main = main.child(
965                div()
966                    .mt(px(8.))
967                    .text_color(text_dim)
968                    .text_size(px(11.))
969                    .child(format!("{total} fila(s)")),
970            );
971        }
972
973        main
974    }
975
976    /// Renderea el selector clickable de records existentes para un
977    /// FieldSpec con kind=EntityRef. Lista compacta debajo del input;
978    /// click en una opción setea el TextInput del field con el UUID
979    /// seleccionado. El item del UUID actualmente seleccionado (si
980    /// hay) se resalta con accent color.
981    /// Renderea una ficha de detalle: campos del record + listas de
982    /// records relacionados (back-references) + botones Volver/Editar.
983    #[allow(clippy::too_many_arguments)]
984    fn render_detail(
985        &self,
986        cx: &mut Context<Self>,
987        mut main: gpui::Div,
988        dv: &DetailView,
989        mod_idx: usize,
990        border: gpui::Hsla,
991        text: gpui::Hsla,
992        text_dim: gpui::Hsla,
993        accent: gpui::Hsla,
994    ) -> gpui::Div {
995        let theme = Theme::global(cx);
996        let action_bg = theme.bg_button();
997        let action_hover = theme.bg_button_hover();
998        let row_separator = theme.bg_row_active;
999
1000        let back_view = self.detail_return.clone();
1001        let mut header = div()
1002            .flex()
1003            .flex_row()
1004            .items_center()
1005            .gap(px(10.))
1006            .mb(px(12.))
1007            .child(
1008                div()
1009                    .text_color(text)
1010                    .text_size(px(18.))
1011                    .flex_grow()
1012                    .child(dv.title.clone()),
1013            )
1014            .child(
1015                div()
1016                    .id("detail-back")
1017                    .px(px(10.))
1018                    .py(px(4.))
1019                    .bg(action_bg)
1020                    .text_color(accent)
1021                    .text_size(px(11.))
1022                    .rounded(px(3.))
1023                    .hover(move |d| d.bg(action_hover))
1024                    .child("← Volver")
1025                    .on_click(cx.listener(move |this, _e: &ClickEvent, _w, cx| {
1026                        match back_view.clone() {
1027                            Some(v) => this.select_view(mod_idx, v, cx),
1028                            None => {
1029                                if let Some(first) = this
1030                                    .modules
1031                                    .get(mod_idx)
1032                                    .and_then(|m| m.menu.first().map(|i| i.view.clone()))
1033                                {
1034                                    this.select_view(mod_idx, first, cx);
1035                                }
1036                            }
1037                        }
1038                    })),
1039            );
1040
1041        let Some(target_id) = self.detail_target else {
1042            return main.child(header).child(
1043                div()
1044                    .text_color(text_dim)
1045                    .child("Ningún record seleccionado."),
1046            );
1047        };
1048
1049        let entity_for_edit = dv.entity.clone();
1050        header = header.child(
1051            div()
1052                .id("detail-edit")
1053                .px(px(10.))
1054                .py(px(4.))
1055                .bg(action_bg)
1056                .text_color(accent)
1057                .text_size(px(11.))
1058                .rounded(px(3.))
1059                .hover(move |d| d.bg(action_hover))
1060                .child("✎ Editar")
1061                .on_click(cx.listener(move |this, _e: &ClickEvent, _w, cx| {
1062                    this.open_edit(mod_idx, entity_for_edit.clone(), target_id, cx);
1063                })),
1064        );
1065        main = main.child(header);
1066
1067        let Some(record) = self.backend.load_record(&dv.entity, target_id) else {
1068            return main.child(div().text_color(text_dim).child(format!(
1069                "El record {} ya no existe.",
1070                short_uuid(&target_id)
1071            )));
1072        };
1073
1074        // Campos del record.
1075        let mut fields_box = div().flex().flex_col();
1076        for c in &dv.fields {
1077            let v = lookup_field(&record, &c.field);
1078            fields_box = fields_box.child(
1079                div()
1080                    .flex()
1081                    .flex_row()
1082                    .gap(px(12.))
1083                    .py(px(4.))
1084                    .border_b_1()
1085                    .border_color(row_separator)
1086                    .child(
1087                        div()
1088                            .w(px(160.))
1089                            .flex_none()
1090                            .text_color(text_dim)
1091                            .text_size(px(12.))
1092                            .child(c.label.clone()),
1093                    )
1094                    .child(
1095                        div()
1096                            .flex_grow()
1097                            .text_color(text)
1098                            .text_size(px(12.))
1099                            .child(self.render_cell(c, v)),
1100                    ),
1101            );
1102        }
1103        main = main.child(fields_box);
1104
1105        // Listas de records relacionados.
1106        for rl in &dv.related {
1107            main = main.child(self.render_related(rl, target_id, border, text, text_dim));
1108        }
1109        main
1110    }
1111
1112    /// Renderea una lista de records relacionados (back-reference):
1113    /// los records de `rl.entity` cuyo `rl.via_field` apunta al record
1114    /// `target_id`.
1115    fn render_related(
1116        &self,
1117        rl: &RelatedList,
1118        target_id: Uuid,
1119        border: gpui::Hsla,
1120        text: gpui::Hsla,
1121        text_dim: gpui::Hsla,
1122    ) -> gpui::Div {
1123        let id_str = target_id.to_string();
1124        let rows: Vec<(Uuid, Value)> = self
1125            .backend
1126            .list_records(&rl.entity)
1127            .into_iter()
1128            .filter(|(_, v)| v.get(&rl.via_field).and_then(Value::as_str) == Some(id_str.as_str()))
1129            .collect();
1130
1131        let mut section = div().flex().flex_col().mt(px(18.)).child(
1132            div()
1133                .text_color(text)
1134                .text_size(px(13.))
1135                .mb(px(4.))
1136                .child(format!("{} ({})", rl.title, rows.len())),
1137        );
1138
1139        if rows.is_empty() {
1140            return section.child(
1141                div()
1142                    .py(px(4.))
1143                    .text_color(text_dim)
1144                    .text_size(px(11.))
1145                    .child("(ninguno)"),
1146            );
1147        }
1148
1149        let total_weight: f32 = rl.columns.iter().map(|c| c.weight).sum::<f32>().max(0.01);
1150        let mut head = div()
1151            .flex()
1152            .flex_row()
1153            .py(px(4.))
1154            .border_b_1()
1155            .border_color(border)
1156            .text_color(text_dim)
1157            .text_size(px(10.));
1158        for c in &rl.columns {
1159            head = head.child(
1160                div()
1161                    .flex_grow()
1162                    .flex_basis(px(100. * c.weight / total_weight))
1163                    .child(c.label.clone()),
1164            );
1165        }
1166        section = section.child(head);
1167
1168        for (_, v) in &rows {
1169            let mut row = div()
1170                .flex()
1171                .flex_row()
1172                .py(px(3.))
1173                .text_color(text)
1174                .text_size(px(11.));
1175            for c in &rl.columns {
1176                row = row.child(
1177                    div()
1178                        .flex_grow()
1179                        .flex_basis(px(100. * c.weight / total_weight))
1180                        .child(self.render_cell(c, lookup_field(v, &c.field))),
1181                );
1182            }
1183            section = section.child(row);
1184        }
1185        section
1186    }
1187
1188    /// Render del valor de una celda de lista. Una columna con
1189    /// `ref_entity` resuelve su UUID al label del record referido; el
1190    /// resto aplica el `ValueFormat` declarado en la columna.
1191    fn render_cell(&self, c: &Column, v: Option<&Value>) -> String {
1192        if let Some(ref_entity) = &c.ref_entity {
1193            return match v {
1194                Some(Value::String(s)) => match Uuid::parse_str(s) {
1195                    Ok(uuid) => self
1196                        .backend
1197                        .load_record(ref_entity, uuid)
1198                        .map(|rec| human_label_for_record(&rec, &uuid))
1199                        .unwrap_or_else(|| format!("(borrado · {})", short_uuid(&uuid))),
1200                    Err(_) => render_value(v),
1201                },
1202                _ => render_value(v),
1203            };
1204        }
1205        format_value(v, &c.format)
1206    }
1207
1208    /// Label legible del record referenciado por un campo EntityRef.
1209    /// `(sin seleccionar)` si el campo está vacío.
1210    fn ref_label(&self, target: &str, current: &str) -> String {
1211        let current = current.trim();
1212        if current.is_empty() {
1213            return "(sin seleccionar)".to_string();
1214        }
1215        match Uuid::parse_str(current) {
1216            Ok(uuid) => self
1217                .backend
1218                .load_record(target, uuid)
1219                .map(|rec| human_label_for_record(&rec, &uuid))
1220                .unwrap_or_else(|| format!("(borrado · {})", short_uuid(&uuid))),
1221            Err(_) => current.to_string(),
1222        }
1223    }
1224
1225    /// Chips clickables para un campo [`FieldKind::Select`]. El chip de
1226    /// la opción elegida se resalta con accent. Click setea el
1227    /// `TextInput` del field (de donde lee el submit), igual que el
1228    /// selector de EntityRef.
1229    fn render_select_chips(
1230        &self,
1231        cx: &mut Context<Self>,
1232        field_name: String,
1233        options: &[SelectOption],
1234        text_dim: gpui::Hsla,
1235        accent: gpui::Hsla,
1236    ) -> gpui::Div {
1237        let theme = Theme::global(cx);
1238        let row_active = theme.bg_row_active;
1239        let row_hover = theme.bg_row_hover;
1240        let border = theme.border;
1241        let current = self
1242            .form_inputs
1243            .get(&field_name)
1244            .map(|inp| inp.read(&*cx).text().to_string())
1245            .unwrap_or_default();
1246
1247        let mut row = div().mt(px(2.)).flex().flex_row().flex_wrap().gap(px(4.));
1248
1249        for opt in options {
1250            let value = opt.value.clone();
1251            let is_selected = current == value;
1252            let field_for_click = field_name.clone();
1253            let value_for_click = value.clone();
1254            row = row.child(
1255                div()
1256                    .id(SharedString::from(format!("select-{field_name}-{value}")))
1257                    .px(px(8.))
1258                    .py(px(3.))
1259                    .rounded(px(10.))
1260                    .border_1()
1261                    .border_color(if is_selected { accent } else { border })
1262                    .text_size(px(11.))
1263                    .text_color(if is_selected { accent } else { text_dim })
1264                    .when(is_selected, move |d| d.bg(row_active))
1265                    .hover(move |d| d.bg(row_hover))
1266                    .child(opt.display().to_string())
1267                    .on_click(cx.listener(move |this, _e: &ClickEvent, _w, cx| {
1268                        if let Some(input) = this.form_inputs.get(&field_for_click) {
1269                            input.update(cx, |inp, cx| inp.set_text(value_for_click.clone(), cx));
1270                        }
1271                        cx.notify();
1272                    })),
1273            );
1274        }
1275        row
1276    }
1277
1278    #[allow(clippy::too_many_arguments)]
1279    fn render_entity_ref_selector(
1280        &self,
1281        cx: &mut Context<Self>,
1282        field_name: String,
1283        target_entity: String,
1284        text: gpui::Hsla,
1285        text_dim: gpui::Hsla,
1286        accent: gpui::Hsla,
1287    ) -> gpui::Div {
1288        let _ = text;
1289        // Slots ornament para hover/selected del selector + border.
1290        let theme = Theme::global(cx);
1291        let row_active = theme.bg_row_active;
1292        let row_hover = theme.bg_row_hover;
1293        let rows = self.list_rows(&target_entity);
1294        let current = self
1295            .form_inputs
1296            .get(&field_name)
1297            .map(|inp| inp.read(&*cx).text().to_string())
1298            .unwrap_or_default();
1299
1300        let mut container = div()
1301            .mt(px(4.))
1302            .pl(px(8.))
1303            .border_l_2()
1304            .border_color(theme.border)
1305            .flex()
1306            .flex_col()
1307            .gap(px(2.));
1308
1309        if rows.is_empty() {
1310            return container.child(
1311                div()
1312                    .px(px(6.))
1313                    .py(px(4.))
1314                    .text_color(text_dim)
1315                    .text_size(px(10.))
1316                    .child(format!(
1317                        "(sin {target_entity}: creá uno antes para referenciar)"
1318                    )),
1319            );
1320        }
1321
1322        container = container.child(
1323            div()
1324                .text_color(text_dim)
1325                .text_size(px(10.))
1326                .child(format!("Seleccioná un {target_entity}:")),
1327        );
1328
1329        for (id, value) in &rows {
1330            let label = human_label_for_record(value, id);
1331            let id_str = id.to_string();
1332            let is_selected = current == id_str;
1333            let field_for_click = field_name.clone();
1334            let id_for_click = id_str.clone();
1335            container = container.child(
1336                div()
1337                    .id(SharedString::from(format!(
1338                        "entity-ref-{field_name}-{id_str}"
1339                    )))
1340                    .px(px(6.))
1341                    .py(px(2.))
1342                    .text_size(px(11.))
1343                    .text_color(if is_selected { accent } else { text_dim })
1344                    .when(is_selected, move |d| d.bg(row_active))
1345                    .hover(move |d| d.bg(row_hover))
1346                    .child(label)
1347                    .on_click(cx.listener(move |this, _e: &ClickEvent, _w, cx| {
1348                        if let Some(input) = this.form_inputs.get(&field_for_click) {
1349                            input.update(cx, |inp, cx| inp.set_text(id_for_click.clone(), cx));
1350                        }
1351                        cx.notify();
1352                    })),
1353            );
1354        }
1355        container
1356    }
1357
1358    #[allow(clippy::too_many_arguments)]
1359    fn render_form(
1360        &self,
1361        cx: &mut Context<Self>,
1362        mut main: gpui::Div,
1363        fv: &FormView,
1364        mod_idx: usize,
1365        _border: gpui::Hsla,
1366        text: gpui::Hsla,
1367        text_dim: gpui::Hsla,
1368        accent: gpui::Hsla,
1369    ) -> gpui::Div {
1370        // Slots ornament para el botón submit + bg de fallback inputs.
1371        let theme = Theme::global(cx);
1372        let submit_bg = theme.bg_button();
1373        let submit_hover = theme.bg_button_hover();
1374        let input_bg = theme.bg_input();
1375        // En modo edit, el título refleja eso para que el user no
1376        // se confunda creyendo que hace alta nueva.
1377        let title = match self.editing.as_ref() {
1378            Some((e, id)) if e == &fv.entity => {
1379                format!("Editar {} {}", fv.entity, short_uuid(id))
1380            }
1381            _ => fv.title.clone(),
1382        };
1383        main = main.child(
1384            div()
1385                .text_color(text)
1386                .text_size(px(18.))
1387                .mb(px(12.))
1388                .child(title),
1389        );
1390        for f in &fv.fields {
1391            let label = if f.required {
1392                format!("{} *", f.label)
1393            } else {
1394                f.label.clone()
1395            };
1396
1397            let mut field_box = div().flex().flex_col().mb(px(10.)).child(
1398                div()
1399                    .text_color(text_dim)
1400                    .text_size(px(11.))
1401                    .mb(px(2.))
1402                    .child(label),
1403            );
1404
1405            // Render según el kind. AutoId y Select NO montan el
1406            // TextInput editable —aunque sí existe en `form_inputs`, de
1407            // donde lee el submit—; presentan su propio control.
1408            match f.kind {
1409                FieldKind::AutoId => {
1410                    // Read-only: el UUID autogenerado, sólo informativo.
1411                    let val = self
1412                        .form_inputs
1413                        .get(&f.name)
1414                        .map(|i| i.read(&*cx).text().to_string())
1415                        .unwrap_or_default();
1416                    field_box = field_box.child(
1417                        div()
1418                            .px(px(8.))
1419                            .py(px(6.))
1420                            .bg(input_bg)
1421                            .text_color(text_dim)
1422                            .text_size(px(11.))
1423                            .child(format!("{val}  ·  automático")),
1424                    );
1425                }
1426                FieldKind::Select => {
1427                    field_box = field_box.child(self.render_select_chips(
1428                        cx,
1429                        f.name.clone(),
1430                        &f.options,
1431                        text_dim,
1432                        accent,
1433                    ));
1434                }
1435                FieldKind::EntityRef => {
1436                    // Display read-only del record elegido (label, no
1437                    // el UUID crudo) + selector clickable debajo. El
1438                    // TextInput vive en `form_inputs` pero no se monta.
1439                    if let Some(target) = &f.ref_entity {
1440                        let current = self
1441                            .form_inputs
1442                            .get(&f.name)
1443                            .map(|i| i.read(&*cx).text().to_string())
1444                            .unwrap_or_default();
1445                        let is_empty = current.trim().is_empty();
1446                        field_box = field_box.child(
1447                            div()
1448                                .px(px(8.))
1449                                .py(px(6.))
1450                                .bg(input_bg)
1451                                .text_color(if is_empty { text_dim } else { text })
1452                                .text_size(px(11.))
1453                                .child(self.ref_label(target, &current)),
1454                        );
1455                        field_box = field_box.child(self.render_entity_ref_selector(
1456                            cx,
1457                            f.name.clone(),
1458                            target.clone(),
1459                            text,
1460                            text_dim,
1461                            accent,
1462                        ));
1463                    }
1464                    // Sin ref_entity es imposible: Module::validate lo
1465                    // rechaza al cargar el módulo.
1466                }
1467                _ => {
1468                    // Mount del TextInput vivo (creado en select_view).
1469                    if let Some(input) = self.form_inputs.get(&f.name) {
1470                        field_box = field_box.child(input.clone());
1471                    } else {
1472                        // No debería pasar — select_view crea inputs por
1473                        // cada field. Fallback estático por seguridad.
1474                        field_box = field_box.child(
1475                            div()
1476                                .px(px(8.))
1477                                .py(px(6.))
1478                                .bg(input_bg)
1479                                .text_color(text_dim)
1480                                .child("(input no inicializado)"),
1481                        );
1482                    }
1483                }
1484            }
1485
1486            if let Some(help) = &f.help {
1487                field_box = field_box.child(
1488                    div()
1489                        .mt(px(2.))
1490                        .text_color(text_dim)
1491                        .text_size(px(10.))
1492                        .child(help.clone()),
1493                );
1494            }
1495            main = main.child(field_box);
1496        }
1497
1498        let editing_this = matches!(
1499            self.editing.as_ref(),
1500            Some((e, _)) if e == &fv.entity
1501        );
1502        let submit_label = match &fv.on_submit {
1503            Action::SeedEntity { entity, .. } => {
1504                if editing_this {
1505                    format!("Guardar cambios en {entity}")
1506                } else {
1507                    format!("Crear {entity}")
1508                }
1509            }
1510            Action::Morphism { name, .. } => format!("Ejecutar {name}"),
1511            Action::OpenView { label, view } => {
1512                label.clone().unwrap_or_else(|| format!("Ir a {view}"))
1513            }
1514        };
1515        let submit_action = fv.on_submit.clone();
1516        main = main.child(
1517            div().mt(px(12.)).child(
1518                div()
1519                    .id(SharedString::from(format!("form-submit-{mod_idx}")))
1520                    .px(px(12.))
1521                    .py(px(6.))
1522                    .bg(submit_bg)
1523                    .text_color(accent)
1524                    .text_size(px(12.))
1525                    .rounded(px(3.))
1526                    .hover(move |d| d.bg(submit_hover))
1527                    .child(submit_label)
1528                    .on_click(cx.listener(move |this, _e: &ClickEvent, _w, cx| {
1529                        this.apply_action(submit_action.clone(), cx);
1530                    })),
1531            ),
1532        );
1533
1534        main = main.child(
1535            div()
1536                .mt(px(20.))
1537                .text_color(text_dim)
1538                .text_size(px(10.))
1539                .child(
1540                    "Tip: click en el campo para enfocar; Enter no envía (todavía), \
1541                     usá el botón. Backspace borra el último carácter.",
1542                ),
1543        );
1544        main
1545    }
1546}
1547
1548#[cfg(test)]
1549mod tests {
1550    //! Tests del widget. Funciones puras (sin GPUI cx) sólo. Los
1551    //! tests de backend impl viven en el binario que provee el
1552    //! backend (ej: nakui-ui). Los helpers movidos a
1553    //! nahual-meta-runtime se testean allí.
1554    use super::*;
1555    use serde_json::json;
1556
1557    #[test]
1558    fn lookup_field_simple_and_nested() {
1559        let v = json!({
1560            "name": "Acme",
1561            "address": { "city": "Bogotá", "country": "CO" }
1562        });
1563        assert_eq!(lookup_field(&v, "name").unwrap(), &json!("Acme"));
1564        assert_eq!(lookup_field(&v, "address.city").unwrap(), &json!("Bogotá"));
1565        assert!(lookup_field(&v, "missing").is_none());
1566        assert!(lookup_field(&v, "address.zipcode").is_none());
1567    }
1568
1569    #[test]
1570    fn append_compact_msg_handles_both_branches() {
1571        // Sin compact: base solo, sin separador.
1572        let s = append_compact_msg("creado X".into(), None);
1573        assert_eq!(s.as_ref(), "creado X");
1574        // Con compact: concatena con "; ".
1575        let s = append_compact_msg(
1576            "creado X".into(),
1577            Some("auto-compact: snapshot @ seq 49".into()),
1578        );
1579        assert_eq!(s.as_ref(), "creado X; auto-compact: snapshot @ seq 49");
1580    }
1581
1582    #[test]
1583    fn format_seed_toast_distinguishes_create_update_no_change() {
1584        let id = Uuid::new_v4();
1585        let outcome_create = WriteOutcome {
1586            id: Some(id),
1587            changed: 1,
1588            post_status: None,
1589        };
1590        let toast = format_seed_toast("Customer", false, &outcome_create);
1591        assert!(toast.starts_with("creado Customer"));
1592
1593        let outcome_update = WriteOutcome {
1594            id: Some(id),
1595            changed: 3,
1596            post_status: None,
1597        };
1598        let toast = format_seed_toast("Customer", true, &outcome_update);
1599        assert!(toast.starts_with("actualizado Customer"));
1600        assert!(toast.contains("(3 campo(s))"));
1601
1602        let outcome_no_change = WriteOutcome::no_change(id);
1603        let toast = format_seed_toast("Customer", true, &outcome_no_change);
1604        assert!(toast.contains("sin cambios"));
1605    }
1606}