//! `nakui-ui` — runtime GPUI de la metainterfaz Nakui. //! //! Carga módulos desde un directorio (cada módulo = un //! `module.json`), monta sidebar con sus menús, y renderea la vista //! activa en el panel principal: //! //! - **List**: tabla de instancias del entity. Botones de acción en //! el header (típicamente "Nuevo" → form). //! - **Form**: campos editables (con `yahweh-widget-text-input` para //! teclado real); al submit, escribe al `MemoryStore` in-process //! via `seed_entity` (alta directa) o por morphism (TODO en este //! iter). //! //! Todo el storage es in-memory por ahora — el escenario "save to //! disk" se materializa cuando el daemon Nakui exista. La //! arquitectura permite swap sin tocar la UI. //! //! ## Uso //! //! ```sh //! NAKUI_MODULES_DIR=examples/nakui-modules cargo run -p nakui-ui //! # default sin env: ./nakui-modules en pwd. //! ``` use std::collections::BTreeMap; use std::path::PathBuf; use std::sync::{Arc, Mutex}; use gpui::{ div, prelude::*, px, App, Application, Bounds, ClickEvent, Context, Entity, IntoElement, Render, SharedString, Window, WindowBounds, WindowOptions, }; use nakui_core::store::{MemoryStore, Store}; use nakui_ui_schema::{ Action, FieldKind, FieldSpec, FormView, ListView, Module, View, }; use serde_json::{json, Value}; use uuid::Uuid; use yahweh_theme::Theme; use yahweh_widget_text_input::TextInput; fn main() { Application::new().run(|cx: &mut App| { // El text input pide Theme::global; instalarlo antes de // crear el window evita que panicee. Theme::install_default(cx); let bounds = Bounds::centered(None, gpui::size(px(1100.), px(720.)), cx); cx.open_window( WindowOptions { window_bounds: Some(WindowBounds::Windowed(bounds)), titlebar: Some(gpui::TitlebarOptions { title: Some(SharedString::from("Nakui")), ..Default::default() }), ..Default::default() }, |_w, cx| cx.new(MetaUi::new), ) .expect("open window"); cx.activate(true); }); } /// Estado del runtime. struct MetaUi { /// Módulos cargados, ordenados por id. modules: Vec, /// Store compartido. Mutado por el submit de los forms. store: Arc>, /// (módulo idx, vista key) actualmente activos. active: Option<(usize, String)>, /// Inputs vivos para el form actual: nombre del campo → TextInput. /// Se reemplaza al cambiar de vista (drop de los anteriores). form_inputs: BTreeMap>, /// Mensaje toast al pie (success de submit, error de carga, etc.). toast: Option, /// Si la carga de módulos falló al inicio. load_error: Option, } impl MetaUi { fn new(_cx: &mut Context) -> Self { let modules_dir = std::env::var("NAKUI_MODULES_DIR") .ok() .map(PathBuf::from) .unwrap_or_else(|| PathBuf::from("nakui-modules")); let (modules, load_error) = match nakui_ui_schema::load_modules_from_dir(&modules_dir) { Ok(m) => (m, None), Err(e) => ( Vec::new(), Some(SharedString::from(format!( "no pude cargar módulos de {}: {e}", modules_dir.display() ))), ), }; let active = modules .first() .and_then(|m| m.menu.first().map(|item| (0usize, item.view.clone()))); Self { modules, store: Arc::new(Mutex::new(MemoryStore::new())), active, form_inputs: BTreeMap::new(), toast: None, load_error, } } /// Cambia la vista activa. Si la nueva vista es un Form, crea /// `TextInput` entities para cada field con su valor por defecto. /// Drop de los inputs anteriores ocurre al sobreescribir el map. fn select_view(&mut self, mod_idx: usize, view_key: String, cx: &mut Context) { self.active = Some((mod_idx, view_key.clone())); self.toast = None; self.form_inputs = BTreeMap::new(); if let Some(module) = self.modules.get(mod_idx) { if let Some(View::Form(form)) = module.views.get(&view_key) { for f in &form.fields { let initial = f.default.clone().unwrap_or_default(); let input = cx.new(|cx| TextInput::new(initial, cx)); self.form_inputs.insert(f.name.clone(), input); } } } cx.notify(); } /// Aplica una acción (click en menú, botón de form, action de /// list). Mutaciones contra el store ocurren acá. fn apply_action(&mut self, action: Action, cx: &mut Context) { let mod_idx = match self.active.as_ref() { Some((i, _)) => *i, None => return, }; match action { Action::OpenView { view, .. } => { self.select_view(mod_idx, view, cx); } Action::SeedEntity { entity, next_view } => { match self.commit_seed(mod_idx, &entity, cx) { Ok(id) => { self.toast = Some(SharedString::from(format!( "creado {entity} {}", short_uuid(&id) ))); if let Some(v) = next_view { self.select_view(mod_idx, v, cx); } else { // Reset inputs al vacío para alta consecutiva. for input in self.form_inputs.values() { input.update(cx, |inp, cx| inp.set_text("", cx)); } } } Err(e) => { self.toast = Some(SharedString::from(format!("error: {e}"))); } } cx.notify(); } Action::Morphism { name, .. } => { self.toast = Some(SharedString::from(format!( "morphism '{name}': pendiente (requiere manifest nakui)" ))); cx.notify(); } } } /// Construye un Value desde los TextInput vivos y lo seedea al store. fn commit_seed( &mut self, mod_idx: usize, entity: &str, cx: &mut Context, ) -> Result { let module = &self.modules[mod_idx]; let spec_fields: Vec = match self.active.as_ref() { Some((_, view_key)) => match module.views.get(view_key) { Some(View::Form(f)) => f.fields.clone(), _ => return Err("la vista activa no es un Form".into()), }, None => return Err("ninguna vista activa".into()), }; let mut obj = serde_json::Map::new(); for f in &spec_fields { let raw = self .form_inputs .get(&f.name) .map(|input| input.read(cx).text().to_string()) .unwrap_or_default(); if f.required && raw.trim().is_empty() { return Err(format!("campo '{}' es obligatorio", f.label)); } if raw.is_empty() && !f.required { continue; } let value = parse_field_value(f.kind, &raw) .map_err(|e| format!("campo '{}': {e}", f.label))?; obj.insert(f.name.clone(), value); } let id = Uuid::new_v4(); if let Ok(mut store) = self.store.lock() { store.seed(entity, id, Value::Object(obj)); Ok(id) } else { Err("store mutex envenenado".into()) } } /// Snapshot ordenado de records de una entity. fn list_rows(&self, entity: &str) -> Vec<(Uuid, Value)> { let store = match self.store.lock() { Ok(g) => g, Err(_) => return Vec::new(), }; let it = match store.iter() { Ok(i) => i, Err(_) => return Vec::new(), }; it.filter(|(e, _, _)| e == entity) .map(|(_, id, v)| (id, v)) .collect() } } fn parse_field_value(kind: FieldKind, raw: &str) -> Result { match kind { FieldKind::Text | FieldKind::Multiline | FieldKind::Date => Ok(json!(raw)), FieldKind::Boolean => match raw.to_ascii_lowercase().as_str() { "true" | "yes" | "1" | "on" | "y" => Ok(json!(true)), "" | "false" | "no" | "0" | "off" | "n" => Ok(json!(false)), other => Err(format!("'{other}' no es booleano")), }, FieldKind::Number => { if let Ok(i) = raw.parse::() { Ok(json!(i)) } else if let Ok(f) = raw.parse::() { Ok(json!(f)) } else { Err(format!("'{raw}' no es número")) } } } } fn lookup_field<'a>(v: &'a Value, path: &str) -> Option<&'a Value> { let mut cur = v; for seg in path.split('.') { cur = cur.get(seg)?; } Some(cur) } fn render_value(v: Option<&Value>) -> String { match v { None | Some(Value::Null) => String::new(), Some(Value::String(s)) => s.clone(), Some(Value::Bool(b)) => if *b { "✓" } else { "✗" }.to_string(), Some(Value::Number(n)) => n.to_string(), Some(other) => other.to_string(), } } fn short_uuid(id: &Uuid) -> String { id.to_string().chars().take(8).collect() } impl Render for MetaUi { fn render(&mut self, _w: &mut Window, cx: &mut Context) -> impl IntoElement { let bg = gpui::rgb(0x14171c); let panel = gpui::rgb(0x1d2128); let border = gpui::rgb(0x2a2f38); let text = gpui::rgb(0xe6e8ec); let text_dim = gpui::rgb(0x9ba1ad); let accent = gpui::rgb(0x88c0d0); let accent_active = gpui::rgb(0xa3be8c); let sidebar = self.render_sidebar(cx, panel, border, text, text_dim, accent_active); let main_panel = self.render_main(cx, panel, border, text, text_dim, accent); let toast_div = self.toast.as_ref().map(|t| { div() .px(px(12.)) .py(px(6.)) .bg(gpui::rgb(0x2d3a2a)) .text_color(gpui::rgb(0xc0e0a0)) .text_size(px(11.)) .child(t.clone()) }); let error_banner = self.load_error.as_ref().map(|e| { div() .px(px(12.)) .py(px(6.)) .bg(gpui::rgb(0x4a2020)) .text_color(gpui::rgb(0xffd0d0)) .text_size(px(11.)) .child(e.clone()) }); div() .flex() .flex_col() .size_full() .bg(bg) .when_some(error_banner, |d, b| d.child(b)) .child( div() .flex() .flex_row() .flex_grow() .child(sidebar) .child(main_panel), ) .when_some(toast_div, |d, t| d.child(t)) } } impl MetaUi { fn render_sidebar( &self, cx: &mut Context, panel: gpui::Rgba, border: gpui::Rgba, text: gpui::Rgba, text_dim: gpui::Rgba, accent_active: gpui::Rgba, ) -> gpui::Div { let mut sidebar = div() .w(px(240.)) .h_full() .bg(panel) .border_r_1() .border_color(border) .flex() .flex_col(); sidebar = sidebar.child( div() .px(px(12.)) .py(px(10.)) .text_color(text) .text_size(px(13.)) .child("Nakui"), ); if self.modules.is_empty() { return sidebar.child( div() .px(px(12.)) .py(px(8.)) .text_color(text_dim) .text_size(px(11.)) .child("(no hay módulos cargados)"), ); } // Snapshot del active para evitar borrow del self adentro de la closure. let active_snapshot = self.active.clone(); for (mod_idx, m) in self.modules.iter().enumerate() { sidebar = sidebar.child( div() .px(px(12.)) .py(px(8.)) .border_t_1() .border_color(border) .text_color(text) .text_size(px(12.)) .child(m.label.clone()), ); for item in &m.menu { let is_active = active_snapshot .as_ref() .map(|(i, v)| *i == mod_idx && v == &item.view) .unwrap_or(false); let label = item .icon .as_deref() .map(|ic| format!("{ic} {}", item.label)) .unwrap_or_else(|| item.label.clone()); let view_key = item.view.clone(); sidebar = sidebar.child( div() .id(SharedString::from(format!( "menu-{}-{}", mod_idx, item.view ))) .px(px(20.)) .py(px(6.)) .text_size(px(12.)) .text_color(if is_active { accent_active } else { text_dim }) .when(is_active, |d| { d.bg(gpui::rgb(0x232a36)).text_color(text) }) .hover(|d| d.bg(gpui::rgb(0x1f2630))) .child(label) .on_click(cx.listener(move |this, _e: &ClickEvent, _w, cx| { this.select_view(mod_idx, view_key.clone(), cx); })), ); } } sidebar } fn render_main( &mut self, cx: &mut Context, panel: gpui::Rgba, border: gpui::Rgba, text: gpui::Rgba, text_dim: gpui::Rgba, accent: gpui::Rgba, ) -> gpui::Div { let main = div() .flex_grow() .h_full() .bg(panel) .flex() .flex_col() .p(px(16.)); let (mod_idx, view_key) = match &self.active { Some(a) => (a.0, a.1.clone()), None => { return main.child( div() .text_color(text_dim) .child("Seleccioná un menú a la izquierda."), ); } }; let view = match self .modules .get(mod_idx) .and_then(|m| m.views.get(&view_key)) { Some(v) => v.clone(), None => { return main.child( div() .text_color(text_dim) .child(format!("Vista no encontrada: {view_key}")), ); } }; match view { View::List(lv) => self.render_list(cx, main, &lv, mod_idx, border, text, text_dim, accent), View::Form(fv) => self.render_form(cx, main, &fv, mod_idx, border, text, text_dim, accent), } } #[allow(clippy::too_many_arguments)] fn render_list( &self, cx: &mut Context, mut main: gpui::Div, lv: &ListView, mod_idx: usize, border: gpui::Rgba, text: gpui::Rgba, text_dim: gpui::Rgba, accent: gpui::Rgba, ) -> gpui::Div { let mut header = div() .flex() .flex_row() .items_center() .gap(px(12.)) .mb(px(12.)) .child( div() .text_color(text) .text_size(px(18.)) .child(lv.title.clone()), ); for (idx, action) in lv.actions.iter().enumerate() { let label = match action { Action::OpenView { label, view } => { label.clone().unwrap_or_else(|| format!("→ {view}")) } Action::SeedEntity { entity, .. } => format!("Seed {entity}"), Action::Morphism { name, .. } => format!("⚡ {name}"), }; let action_clone = action.clone(); header = header.child( div() .id(SharedString::from(format!( "list-action-{mod_idx}-{idx}" ))) .px(px(10.)) .py(px(4.)) .bg(gpui::rgb(0x232a36)) .text_color(accent) .text_size(px(11.)) .rounded(px(3.)) .hover(|d| d.bg(gpui::rgb(0x2c3540))) .child(label) .on_click(cx.listener(move |this, _e: &ClickEvent, _w, cx| { this.apply_action(action_clone.clone(), cx); })), ); } main = main.child(header); let rows = self.list_rows(&lv.entity); let total = rows.len(); let total_weight: f32 = lv.columns.iter().map(|c| c.weight).sum::().max(0.01); let mut col_header = div() .flex() .flex_row() .py(px(6.)) .border_b_1() .border_color(border) .text_color(text_dim) .text_size(px(11.)); for c in &lv.columns { let frac = c.weight / total_weight; col_header = col_header.child( div() .flex_grow() .flex_basis(px(100. * frac)) .child(c.label.clone()), ); } col_header = col_header.child(div().w(px(80.)).text_color(text_dim).child("id")); main = main.child(col_header); for (id, value) in &rows { let mut row = div() .flex() .flex_row() .py(px(6.)) .border_b_1() .border_color(gpui::rgb(0x232a36)) .text_color(text) .text_size(px(12.)); for c in &lv.columns { let frac = c.weight / total_weight; let v = lookup_field(value, &c.field); row = row.child( div() .flex_grow() .flex_basis(px(100. * frac)) .child(render_value(v)), ); } row = row.child( div() .w(px(80.)) .text_color(text_dim) .text_size(px(11.)) .child(short_uuid(id)), ); main = main.child(row); } if rows.is_empty() { main = main.child( div() .py(px(12.)) .text_color(text_dim) .text_size(px(12.)) .child(format!("(sin {})", lv.entity)), ); } else { main = main.child( div() .mt(px(8.)) .text_color(text_dim) .text_size(px(11.)) .child(format!("{total} fila(s)")), ); } main } #[allow(clippy::too_many_arguments)] fn render_form( &self, cx: &mut Context, mut main: gpui::Div, fv: &FormView, mod_idx: usize, _border: gpui::Rgba, text: gpui::Rgba, text_dim: gpui::Rgba, accent: gpui::Rgba, ) -> gpui::Div { main = main.child( div() .text_color(text) .text_size(px(18.)) .mb(px(12.)) .child(fv.title.clone()), ); for f in &fv.fields { let label = if f.required { format!("{} *", f.label) } else { f.label.clone() }; let mut field_box = div().flex().flex_col().mb(px(10.)).child( div() .text_color(text_dim) .text_size(px(11.)) .mb(px(2.)) .child(label), ); // Mount del TextInput vivo (creado en select_view). if let Some(input) = self.form_inputs.get(&f.name) { field_box = field_box.child(input.clone()); } else { // No debería pasar — select_view crea inputs por cada // field. Fallback display estático por seguridad. field_box = field_box.child( div() .px(px(8.)) .py(px(6.)) .bg(gpui::rgb(0x171a20)) .text_color(text_dim) .child("(input no inicializado)"), ); } if let Some(help) = &f.help { field_box = field_box.child( div() .mt(px(2.)) .text_color(text_dim) .text_size(px(10.)) .child(help.clone()), ); } main = main.child(field_box); } let submit_label = match &fv.on_submit { Action::SeedEntity { entity, .. } => format!("Crear {entity}"), Action::Morphism { name, .. } => format!("Ejecutar {name}"), Action::OpenView { label, view } => { label.clone().unwrap_or_else(|| format!("Ir a {view}")) } }; let submit_action = fv.on_submit.clone(); main = main.child( div().mt(px(12.)).child( div() .id(SharedString::from(format!("form-submit-{mod_idx}"))) .px(px(12.)) .py(px(6.)) .bg(gpui::rgb(0x2c3540)) .text_color(accent) .text_size(px(12.)) .rounded(px(3.)) .hover(|d| d.bg(gpui::rgb(0x3a4555))) .child(submit_label) .on_click(cx.listener(move |this, _e: &ClickEvent, _w, cx| { this.apply_action(submit_action.clone(), cx); })), ), ); main = main.child( div() .mt(px(20.)) .text_color(text_dim) .text_size(px(10.)) .child( "Tip: click en el campo para enfocar; Enter no envía (todavía), \ usá el botón. Backspace borra el último carácter.", ), ); main } } #[cfg(test)] mod tests { use super::*; #[test] fn parse_field_text_returns_string() { assert_eq!(parse_field_value(FieldKind::Text, "hola").unwrap(), json!("hola")); } #[test] fn parse_field_number_int_then_float() { assert_eq!(parse_field_value(FieldKind::Number, "42").unwrap(), json!(42)); assert_eq!(parse_field_value(FieldKind::Number, "3.14").unwrap(), json!(3.14)); } #[test] fn parse_field_number_invalid_errors() { assert!(parse_field_value(FieldKind::Number, "not-a-number").is_err()); } #[test] fn parse_field_boolean_variants() { assert_eq!(parse_field_value(FieldKind::Boolean, "true").unwrap(), json!(true)); assert_eq!(parse_field_value(FieldKind::Boolean, "yes").unwrap(), json!(true)); assert_eq!(parse_field_value(FieldKind::Boolean, "false").unwrap(), json!(false)); assert_eq!(parse_field_value(FieldKind::Boolean, "").unwrap(), json!(false)); assert!(parse_field_value(FieldKind::Boolean, "maybe").is_err()); } #[test] fn lookup_field_simple_and_nested() { let v = json!({ "name": "Acme", "address": { "city": "Bogotá", "country": "CO" } }); assert_eq!(lookup_field(&v, "name").unwrap(), &json!("Acme")); assert_eq!(lookup_field(&v, "address.city").unwrap(), &json!("Bogotá")); assert!(lookup_field(&v, "missing").is_none()); assert!(lookup_field(&v, "address.zipcode").is_none()); } #[test] fn render_value_handles_null_string_bool() { assert_eq!(render_value(None), ""); assert_eq!(render_value(Some(&json!(null))), ""); assert_eq!(render_value(Some(&json!("x"))), "x"); assert_eq!(render_value(Some(&json!(true))), "✓"); assert_eq!(render_value(Some(&json!(false))), "✗"); assert_eq!(render_value(Some(&json!(42))), "42"); } }