gioser

tawasuyu/brahman

Rust 534TOML 203Markdown 58JSON 197690 Sutichaykuna

⎇ ea074d7d576114d3335c4b84d81725dffe09a2ed crates/modules/ui_engine/widgets/meta-form/src/lib.rs Rust · 1238 siq'ikuna · 47.1 KB Ñawpa willay Pi imata llamkarqan ch'uya

feat(yahweh): caret blinking + slots ornament en theme + MetaApp full themed ea074d7d5 · Sergio ·

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