gioser

tawasuyu/brahman

Rust 570TOML 215Markdown 68JSON 198261 Definitions

⎇ 86d06da020b21905887b8e689fe014b5c3cc6e8f crates/modules/nahual/widgets/meta-form/src/lib.rs Rust · 1299 lines · 50.4 KB History Who touched what raw

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