feat(nakui-ui): inputs reales + click handlers funcionales

Cierra dos limitaciones documentadas del commit anterior: los
formularios ahora aceptan teclado real, y los clicks en menus +
botones mutan estado correctamente.

Cambios:
- Cada FieldSpec del Form materializa un Entity<TextInput> de
  yahweh-widget-text-input al entrar a la vista. Los entities se
  reemplazan al cambiar (drop limpio). Soporta: escribir caracteres,
  Backspace, Enter (Confirmed event no usado todavia), Escape.
  Cursor renderea como "|" al final.
- Click handlers wired via cx.listener: menus invocan select_view,
  botones invocan apply_action. Tienen acceso real al
  Context<MetaUi> y mutan el modelo + cx.notify.
- commit_seed reemplaza el buffer ad-hoc por
  input.read(cx).text() por cada field. El value parseado va al
  MemoryStore con tipo correcto.
- Reset de inputs tras submit (set_text("")) si no hay next_view —
  flujo de alta consecutiva sin re-tipear.
- Hover states en sidebar y botones.
- Theme::install_default(cx) al inicio (requerido por text_input).

Wire: deps nuevas yahweh-widget-text-input + yahweh-theme.

Limitaciones que siguen:
- Action::Morphism: requiere cargar Manifest de nakui-core.
- Sin persistencia entre runs (wire con EventLog cuando daemon Nakui
  exista).
- Widget input es simple (sin cursor positioning, selection, IME,
  multilinea, copy/paste).
- Enter no envia (TextInputEvent::Confirmed no suscrito; submit va
  por click). Trivial de wirear si se necesita.

Tests: 6 unit verdes. Visual requiere cargo run + manual.

Activacion:
  NAKUI_MODULES_DIR=examples/nakui-modules cargo run -p nakui-ui
This commit is contained in:
Sergio
2026-05-09 20:11:33 +00:00
parent 06c4fb9130
commit 5d584ff815
3 changed files with 238 additions and 229 deletions
+61
View File
@@ -6,6 +6,67 @@ ratio/diff ver `git show <sha>`.
## 2026-05-09 ## 2026-05-09
### feat(nakui-ui): inputs reales con yahweh-widget-text-input + click handlers funcionales
Cierra dos limitaciones documentadas en el commit anterior de la
metainterfaz: los formularios ahora aceptan teclado real, y los
clicks en menús + botones mutan estado correctamente.
Cambios:
- **Inputs vivos**: cada `FieldSpec` del Form view materializa un
`Entity<TextInput>` (de `yahweh-widget-text-input`) al entrar a la
vista. Los entities se reemplazan al cambiar de view (drop limpio).
El widget soporta: escribir caracteres, Backspace, Enter (Confirmed
event — no usado todavía; el submit va por botón), Escape
(Cancelled). El cursor se renderea como `|` al final.
- **Click handlers wired vía `cx.listener`**: menús del sidebar
invocan `select_view`; botones de acción (header de list, submit
de form) invocan `apply_action`. Los handlers tienen acceso real
al `Context<MetaUi>` y mutan el modelo + emiten `cx.notify()`.
- **Submit lee texto de los inputs**: `commit_seed` reemplaza el
buffer ad-hoc anterior por `input.read(cx).text()` por cada
field. El value parseado va al `MemoryStore` con su tipo correcto
(text/number/boolean/date).
- **Reset de inputs tras submit**: si la acción no tiene `next_view`,
los inputs se vacían (`set_text("")`) para alta consecutiva sin
re-tipear.
- **Hover states**: items del sidebar y botones cambian de bg al
pasar el mouse, feedback visual consistente con el resto del
ecosistema yahweh.
- **Theme global**: `Theme::install_default(cx)` al inicio (lo
requiere el text_input para sus colores).
Wire en Cargo:
- Deps nuevas: `yahweh-widget-text-input`, `yahweh-theme` (paths
relativos al monorepo).
Limitaciones que **siguen abiertas** (próximos iters):
- **`Action::Morphism`** sigue como TODO: requiere cargar el
`Manifest` de nakui-core junto al `Module` UI para conocer los
inputs/params declarados.
- **Sin persistencia entre runs**: `MemoryStore` en RAM. Wire con
`EventLog` o `SurrealStore` queda para cuando exista el daemon
Nakui.
- **Inputs simples**: el widget no soporta cursor positioning,
selection, copy/paste, IME, multilínea. Para edits serios habrá
que portar `gpui::examples::input` o adoptar `gpui-input` cuando
exista upstream.
- **Enter no envía**: el `TextInputEvent::Confirmed` que emite el
widget no está suscrito todavía; el submit va por click. Trivial
de wirear si lo necesitamos.
Tests: los 6 unit del runtime siguen verdes (parse_field_value para
los 5 kinds, lookup_field nested, render_value). El comportamiento
visual requiere correr el binario con `cargo run -p nakui-ui` y
probar a mano — GPUI no provee harness de UI testing en CI hoy.
Activación full:
```sh
NAKUI_MODULES_DIR=examples/nakui-modules cargo run -p nakui-ui
# Click en un menú → carga vista. Click en "Nuevo" → form.
# Tipear en cada campo → ver el `|` al final. Click "Crear customer"
# → record aparece en la lista.
```
### feat(nakui): metainterfaz declarativa + 6 módulos ERP estándar ### feat(nakui): metainterfaz declarativa + 6 módulos ERP estándar
Salto cualitativo: Nakui pasa de "library + demos + read-only viewer Salto cualitativo: Nakui pasa de "library + demos + read-only viewer
del event log" a **plataforma ERP con UI dirigida por datos**. Cada del event log" a **plataforma ERP con UI dirigida por datos**. Cada
+2
View File
@@ -8,6 +8,8 @@ description = "Nakui — runtime GPUI de la metainterfaz: carga module.json desd
[dependencies] [dependencies]
nakui-core = { path = "../../modules/nakui/core" } nakui-core = { path = "../../modules/nakui/core" }
nakui-ui-schema = { path = "../../modules/nakui/ui-schema" } nakui-ui-schema = { path = "../../modules/nakui/ui-schema" }
yahweh-widget-text-input = { path = "../../modules/ui_engine/widgets/text_input" }
yahweh-theme = { path = "../../modules/ui_engine/libs/theme" }
gpui = { workspace = true } gpui = { workspace = true }
serde_json = { workspace = true } serde_json = { workspace = true }
uuid = { workspace = true, features = ["serde"] } uuid = { workspace = true, features = ["serde"] }
+175 -229
View File
@@ -4,11 +4,12 @@
//! `module.json`), monta sidebar con sus menús, y renderea la vista //! `module.json`), monta sidebar con sus menús, y renderea la vista
//! activa en el panel principal: //! activa en el panel principal:
//! //!
//! - **List**: tabla de instancias de la entity. Botones de acción //! - **List**: tabla de instancias del entity. Botones de acción en
//! en el header (típicamente "Nuevo" → form). //! el header (típicamente "Nuevo" → form).
//! - **Form**: campos editables; al submit, escribe al `MemoryStore` //! - **Form**: campos editables (con `yahweh-widget-text-input` para
//! in-process via `seed_and_log` (alta directa) o por morphism //! teclado real); al submit, escribe al `MemoryStore` in-process
//! (TODO en este iter). //! via `seed_entity` (alta directa) o por morphism (TODO en este
//! iter).
//! //!
//! Todo el storage es in-memory por ahora — el escenario "save to //! Todo el storage es in-memory por ahora — el escenario "save to
//! disk" se materializa cuando el daemon Nakui exista. La //! disk" se materializa cuando el daemon Nakui exista. La
@@ -21,22 +22,29 @@
//! # default sin env: ./nakui-modules en pwd. //! # default sin env: ./nakui-modules en pwd.
//! ``` //! ```
use std::collections::BTreeMap;
use std::path::PathBuf; use std::path::PathBuf;
use std::sync::{Arc, Mutex}; use std::sync::{Arc, Mutex};
use gpui::{ use gpui::{
div, prelude::*, px, rgb, App, Application, Bounds, ClickEvent, Context, IntoElement, Render, div, prelude::*, px, App, Application, Bounds, ClickEvent, Context, Entity, IntoElement,
SharedString, Window, WindowBounds, WindowOptions, Render, SharedString, Window, WindowBounds, WindowOptions,
}; };
use nakui_core::store::{MemoryStore, Store}; use nakui_core::store::{MemoryStore, Store};
use nakui_ui_schema::{ use nakui_ui_schema::{
Action, Column, FieldKind, FieldSpec, FormView, ListView, MenuItem, Module, View, Action, FieldKind, FieldSpec, FormView, ListView, Module, View,
}; };
use serde_json::{json, Value}; use serde_json::{json, Value};
use uuid::Uuid; use uuid::Uuid;
use yahweh_theme::Theme;
use yahweh_widget_text_input::TextInput;
fn main() { fn main() {
Application::new().run(|cx: &mut App| { 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); let bounds = Bounds::centered(None, gpui::size(px(1100.), px(720.)), cx);
cx.open_window( cx.open_window(
WindowOptions { WindowOptions {
@@ -60,16 +68,14 @@ struct MetaUi {
modules: Vec<Module>, modules: Vec<Module>,
/// Store compartido. Mutado por el submit de los forms. /// Store compartido. Mutado por el submit de los forms.
store: Arc<Mutex<MemoryStore>>, store: Arc<Mutex<MemoryStore>>,
/// Módulo + vista actualmente seleccionados (índices a `modules` /// (módulo idx, vista key) actualmente activos.
/// y key dentro de `views` respectivamente).
active: Option<(usize, String)>, active: Option<(usize, String)>,
/// Buffer del form actual: nombre del campo → valor texto. Se /// Inputs vivos para el form actual: nombre del campo → TextInput.
/// resetea al cambiar de vista. /// Se reemplaza al cambiar de vista (drop de los anteriores).
form_buffer: std::collections::BTreeMap<String, String>, form_inputs: BTreeMap<String, Entity<TextInput>>,
/// Mensaje toast al pie (success de submit, error de carga, etc.). /// Mensaje toast al pie (success de submit, error de carga, etc.).
toast: Option<SharedString>, toast: Option<SharedString>,
/// Si la carga de módulos falló al inicio, lo guardamos para /// Si la carga de módulos falló al inicio.
/// mostrarlo como banner de error permanente.
load_error: Option<SharedString>, load_error: Option<SharedString>,
} }
@@ -92,7 +98,6 @@ impl MetaUi {
), ),
}; };
// Auto-seleccionar la primera vista del primer módulo si hay.
let active = modules let active = modules
.first() .first()
.and_then(|m| m.menu.first().map(|item| (0usize, item.view.clone()))); .and_then(|m| m.menu.first().map(|item| (0usize, item.view.clone())));
@@ -101,62 +106,81 @@ impl MetaUi {
modules, modules,
store: Arc::new(Mutex::new(MemoryStore::new())), store: Arc::new(Mutex::new(MemoryStore::new())),
active, active,
form_buffer: Default::default(), form_inputs: BTreeMap::new(),
toast: None, toast: None,
load_error, load_error,
} }
} }
fn select_view(&mut self, mod_idx: usize, view_key: String) { /// Cambia la vista activa. Si la nueva vista es un Form, crea
self.active = Some((mod_idx, view_key)); /// `TextInput` entities para cada field con su valor por defecto.
self.form_buffer.clear(); /// 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>) {
self.active = Some((mod_idx, view_key.clone()));
self.toast = None; 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 /// Aplica una acción (click en menú, botón de form, action de
/// list). Mutaciones contra el store ocurren acá. /// list). Mutaciones contra el store ocurren acá.
fn apply_action(&mut self, action: &Action) { fn apply_action(&mut self, action: Action, cx: &mut Context<Self>) {
let mod_idx = match self.active.as_ref() { let mod_idx = match self.active.as_ref() {
Some((i, _)) => *i, Some((i, _)) => *i,
None => return, None => return,
}; };
match action { match action {
Action::OpenView { view, .. } => { Action::OpenView { view, .. } => {
self.select_view(mod_idx, view.clone()); self.select_view(mod_idx, view, cx);
} }
Action::SeedEntity { entity, next_view } => { Action::SeedEntity { entity, next_view } => {
match self.commit_seed(mod_idx, entity) { match self.commit_seed(mod_idx, &entity, cx) {
Ok(id) => { Ok(id) => {
self.toast = Some(SharedString::from(format!( self.toast = Some(SharedString::from(format!(
"creado {entity} {}", "creado {entity} {}",
short_uuid(&id) short_uuid(&id)
))); )));
if let Some(v) = next_view { if let Some(v) = next_view {
self.select_view(mod_idx, v.clone()); self.select_view(mod_idx, v, cx);
} else { } else {
self.form_buffer.clear(); // 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) => { Err(e) => {
self.toast = Some(SharedString::from(format!("error: {e}"))); self.toast = Some(SharedString::from(format!("error: {e}")));
} }
} }
cx.notify();
} }
Action::Morphism { name, .. } => { Action::Morphism { name, .. } => {
// Pipeline morphism completo (executor + event_log)
// requiere un Manifest cargado. Fuera de scope para
// este MVP; toast informativo.
self.toast = Some(SharedString::from(format!( self.toast = Some(SharedString::from(format!(
"morphism '{name}': pendiente (requiere manifest nakui)" "morphism '{name}': pendiente (requiere manifest nakui)"
))); )));
cx.notify();
} }
} }
} }
/// Construye un Value desde el form buffer y lo seedea al store. /// Construye un Value desde los TextInput vivos y lo seedea al store.
fn commit_seed(&mut self, mod_idx: usize, entity: &str) -> Result<Uuid, String> { fn commit_seed(
&mut self,
mod_idx: usize,
entity: &str,
cx: &mut Context<Self>,
) -> Result<Uuid, String> {
let module = &self.modules[mod_idx]; let module = &self.modules[mod_idx];
// Recoge la spec del FormView activo para conocer field kinds.
let spec_fields: Vec<FieldSpec> = match self.active.as_ref() { let spec_fields: Vec<FieldSpec> = match self.active.as_ref() {
Some((_, view_key)) => match module.views.get(view_key) { Some((_, view_key)) => match module.views.get(view_key) {
Some(View::Form(f)) => f.fields.clone(), Some(View::Form(f)) => f.fields.clone(),
@@ -166,7 +190,11 @@ impl MetaUi {
}; };
let mut obj = serde_json::Map::new(); let mut obj = serde_json::Map::new();
for f in &spec_fields { for f in &spec_fields {
let raw = self.form_buffer.get(&f.name).cloned().unwrap_or_default(); 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() { if f.required && raw.trim().is_empty() {
return Err(format!("campo '{}' es obligatorio", f.label)); return Err(format!("campo '{}' es obligatorio", f.label));
} }
@@ -186,9 +214,7 @@ impl MetaUi {
} }
} }
/// Snapshot ordenado de records de una entity (entity → rows). /// Snapshot ordenado de records de una entity.
/// Materializa a Vec antes de soltar el lock — el iterator del
/// Store traer un borrow que no sobrevive al drop del guard.
fn list_rows(&self, entity: &str) -> Vec<(Uuid, Value)> { fn list_rows(&self, entity: &str) -> Vec<(Uuid, Value)> {
let store = match self.store.lock() { let store = match self.store.lock() {
Ok(g) => g, Ok(g) => g,
@@ -198,11 +224,9 @@ impl MetaUi {
Ok(i) => i, Ok(i) => i,
Err(_) => return Vec::new(), Err(_) => return Vec::new(),
}; };
let out: Vec<(Uuid, Value)> = it it.filter(|(e, _, _)| e == entity)
.filter(|(e, _, _)| e == entity)
.map(|(_, id, v)| (id, v)) .map(|(_, id, v)| (id, v))
.collect(); .collect()
out
} }
} }
@@ -226,8 +250,6 @@ fn parse_field_value(kind: FieldKind, raw: &str) -> Result<Value, String> {
} }
} }
/// Navegación por path con puntos para columns nested.
/// Ej: `address.city` → v["address"]["city"].
fn lookup_field<'a>(v: &'a Value, path: &str) -> Option<&'a Value> { fn lookup_field<'a>(v: &'a Value, path: &str) -> Option<&'a Value> {
let mut cur = v; let mut cur = v;
for seg in path.split('.') { for seg in path.split('.') {
@@ -252,13 +274,13 @@ fn short_uuid(id: &Uuid) -> String {
impl Render for MetaUi { impl Render for MetaUi {
fn render(&mut self, _w: &mut Window, cx: &mut Context<Self>) -> impl IntoElement { fn render(&mut self, _w: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let bg = rgb(0x14171c); let bg = gpui::rgb(0x14171c);
let panel = rgb(0x1d2128); let panel = gpui::rgb(0x1d2128);
let border = rgb(0x2a2f38); let border = gpui::rgb(0x2a2f38);
let text = rgb(0xe6e8ec); let text = gpui::rgb(0xe6e8ec);
let text_dim = rgb(0x9ba1ad); let text_dim = gpui::rgb(0x9ba1ad);
let accent = rgb(0x88c0d0); let accent = gpui::rgb(0x88c0d0);
let accent_active = rgb(0xa3be8c); let accent_active = gpui::rgb(0xa3be8c);
let sidebar = self.render_sidebar(cx, panel, border, text, text_dim, accent_active); 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 main_panel = self.render_main(cx, panel, border, text, text_dim, accent);
@@ -266,8 +288,8 @@ impl Render for MetaUi {
div() div()
.px(px(12.)) .px(px(12.))
.py(px(6.)) .py(px(6.))
.bg(rgb(0x2d3a2a)) .bg(gpui::rgb(0x2d3a2a))
.text_color(rgb(0xc0e0a0)) .text_color(gpui::rgb(0xc0e0a0))
.text_size(px(11.)) .text_size(px(11.))
.child(t.clone()) .child(t.clone())
}); });
@@ -275,8 +297,8 @@ impl Render for MetaUi {
div() div()
.px(px(12.)) .px(px(12.))
.py(px(6.)) .py(px(6.))
.bg(rgb(0x4a2020)) .bg(gpui::rgb(0x4a2020))
.text_color(rgb(0xffd0d0)) .text_color(gpui::rgb(0xffd0d0))
.text_size(px(11.)) .text_size(px(11.))
.child(e.clone()) .child(e.clone())
}); });
@@ -338,6 +360,9 @@ impl MetaUi {
); );
} }
// 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() { for (mod_idx, m) in self.modules.iter().enumerate() {
sidebar = sidebar.child( sidebar = sidebar.child(
div() div()
@@ -351,8 +376,7 @@ impl MetaUi {
); );
for item in &m.menu { for item in &m.menu {
let is_active = self let is_active = active_snapshot
.active
.as_ref() .as_ref()
.map(|(i, v)| *i == mod_idx && v == &item.view) .map(|(i, v)| *i == mod_idx && v == &item.view)
.unwrap_or(false); .unwrap_or(false);
@@ -362,58 +386,33 @@ impl MetaUi {
.map(|ic| format!("{ic} {}", item.label)) .map(|ic| format!("{ic} {}", item.label))
.unwrap_or_else(|| item.label.clone()); .unwrap_or_else(|| item.label.clone());
let view_key = item.view.clone();
sidebar = sidebar.child( sidebar = sidebar.child(
self.menu_item_button( div()
cx, .id(SharedString::from(format!(
mod_idx, "menu-{}-{}",
item.view.clone(), mod_idx, item.view
label, )))
is_active, .px(px(20.))
text, .py(px(6.))
text_dim, .text_size(px(12.))
accent_active, .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 sidebar
} }
#[allow(clippy::too_many_arguments)]
fn menu_item_button(
&self,
_cx: &mut Context<Self>,
mod_idx: usize,
view_key: String,
label: String,
is_active: bool,
text: gpui::Rgba,
text_dim: gpui::Rgba,
accent: gpui::Rgba,
) -> gpui::Stateful<gpui::Div> {
let id = format!("menu-{}-{}", mod_idx, view_key);
let entity = self.entity_id_for_action(&id);
div()
.id(SharedString::from(entity))
.px(px(20.))
.py(px(6.))
.text_size(px(12.))
.text_color(if is_active { accent } else { text_dim })
.when(is_active, |d| {
d.bg(rgb(0x232a36)).text_color(text)
})
.child(label)
.on_click(cx_handler_view(mod_idx, view_key))
}
fn entity_id_for_action(&self, base: &str) -> String {
// Helper para el id de la div clickable. GPUI requiere que
// las divs `Stateful` tengan un id único por scope.
base.to_string()
}
fn render_main( fn render_main(
&self, &mut self,
cx: &mut Context<Self>, cx: &mut Context<Self>,
panel: gpui::Rgba, panel: gpui::Rgba,
border: gpui::Rgba, border: gpui::Rgba,
@@ -440,24 +439,24 @@ impl MetaUi {
} }
}; };
let module = match self.modules.get(mod_idx) { let view = match self
Some(m) => m, .modules
None => return main.child(div().text_color(text_dim).child("Módulo inválido")), .get(mod_idx)
}; .and_then(|m| m.views.get(&view_key))
let view = match module.views.get(&view_key) { {
Some(v) => v, Some(v) => v.clone(),
None => { None => {
return main.child( return main.child(
div() div()
.text_color(text_dim) .text_color(text_dim)
.child(format!("Vista no encontrada: {view_key}")), .child(format!("Vista no encontrada: {view_key}")),
) );
} }
}; };
match view { match view {
View::List(lv) => self.render_list(cx, main, lv, mod_idx, border, text, text_dim, accent), 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), View::Form(fv) => self.render_form(cx, main, &fv, mod_idx, border, text, text_dim, accent),
} }
} }
@@ -473,7 +472,6 @@ impl MetaUi {
text_dim: gpui::Rgba, text_dim: gpui::Rgba,
accent: gpui::Rgba, accent: gpui::Rgba,
) -> gpui::Div { ) -> gpui::Div {
// Header con título + acciones.
let mut header = div() let mut header = div()
.flex() .flex()
.flex_row() .flex_row()
@@ -494,20 +492,30 @@ impl MetaUi {
Action::SeedEntity { entity, .. } => format!("Seed {entity}"), Action::SeedEntity { entity, .. } => format!("Seed {entity}"),
Action::Morphism { name, .. } => format!("{name}"), Action::Morphism { name, .. } => format!("{name}"),
}; };
header = header.child(action_button( let action_clone = action.clone();
cx, header = header.child(
format!("list-action-{mod_idx}-{idx}"), div()
label, .id(SharedString::from(format!(
action.clone(), "list-action-{mod_idx}-{idx}"
accent, )))
)); .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); main = main.child(header);
let rows = self.list_rows(&lv.entity); let rows = self.list_rows(&lv.entity);
let total = rows.len(); let total = rows.len();
// Header de columnas.
let total_weight: f32 = lv.columns.iter().map(|c| c.weight).sum::<f32>().max(0.01); let total_weight: f32 = lv.columns.iter().map(|c| c.weight).sum::<f32>().max(0.01);
let mut col_header = div() let mut col_header = div()
.flex() .flex()
@@ -526,22 +534,16 @@ impl MetaUi {
.child(c.label.clone()), .child(c.label.clone()),
); );
} }
col_header = col_header.child( col_header = col_header.child(div().w(px(80.)).text_color(text_dim).child("id"));
div()
.w(px(80.))
.text_color(text_dim)
.child("id"),
);
main = main.child(col_header); main = main.child(col_header);
// Filas.
for (id, value) in &rows { for (id, value) in &rows {
let mut row = div() let mut row = div()
.flex() .flex()
.flex_row() .flex_row()
.py(px(6.)) .py(px(6.))
.border_b_1() .border_b_1()
.border_color(rgb(0x232a36)) .border_color(gpui::rgb(0x232a36))
.text_color(text) .text_color(text)
.text_size(px(12.)); .text_size(px(12.));
for c in &lv.columns { for c in &lv.columns {
@@ -592,12 +594,11 @@ impl MetaUi {
mut main: gpui::Div, mut main: gpui::Div,
fv: &FormView, fv: &FormView,
mod_idx: usize, mod_idx: usize,
border: gpui::Rgba, _border: gpui::Rgba,
text: gpui::Rgba, text: gpui::Rgba,
text_dim: gpui::Rgba, text_dim: gpui::Rgba,
accent: gpui::Rgba, accent: gpui::Rgba,
) -> gpui::Div { ) -> gpui::Div {
let _ = border;
main = main.child( main = main.child(
div() div()
.text_color(text) .text_color(text)
@@ -606,49 +607,36 @@ impl MetaUi {
.child(fv.title.clone()), .child(fv.title.clone()),
); );
for f in &fv.fields { for f in &fv.fields {
let raw = self.form_buffer.get(&f.name).cloned().unwrap_or_default();
let display = if raw.is_empty() {
f.default.clone().unwrap_or_default()
} else {
raw
};
let label = if f.required { let label = if f.required {
format!("{} *", f.label) format!("{} *", f.label)
} else { } else {
f.label.clone() f.label.clone()
}; };
let mut field_box = div()
.flex() let mut field_box = div().flex().flex_col().mb(px(10.)).child(
.flex_col() div()
.mb(px(10.)) .text_color(text_dim)
.child( .text_size(px(11.))
div() .mb(px(2.))
.text_color(text_dim) .child(label),
.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) {
.child( field_box = field_box.child(input.clone());
// GPUI no incluye un text_input nativo; mostramos } else {
// el buffer actual como texto. Para entrada // No debería pasar — select_view crea inputs por cada
// teclado real, integrar yahweh-widget-text-input // field. Fallback display estático por seguridad.
// (próxima iteración). Por ahora el form sirve field_box = field_box.child(
// demos visuales y el seed via API programática.
div() div()
.px(px(8.)) .px(px(8.))
.py(px(6.)) .py(px(6.))
.bg(rgb(0x171a20)) .bg(gpui::rgb(0x171a20))
.border_1() .text_color(text_dim)
.border_color(rgb(0x2a2f38)) .child("(input no inicializado)"),
.rounded(px(3.))
.text_color(text)
.text_size(px(12.))
.child(if display.is_empty() {
"(vacío — input GPUI pendiente)".to_string()
} else {
display
}),
); );
}
if let Some(help) = &f.help { if let Some(help) = &f.help {
field_box = field_box.child( field_box = field_box.child(
div() div()
@@ -668,14 +656,23 @@ impl MetaUi {
label.clone().unwrap_or_else(|| format!("Ir a {view}")) label.clone().unwrap_or_else(|| format!("Ir a {view}"))
} }
}; };
let submit_action = fv.on_submit.clone();
main = main.child( main = main.child(
div().mt(px(12.)).child(action_button( div().mt(px(12.)).child(
cx, div()
format!("form-submit-{mod_idx}"), .id(SharedString::from(format!("form-submit-{mod_idx}")))
submit_label, .px(px(12.))
fv.on_submit.clone(), .py(px(6.))
accent, .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( main = main.child(
@@ -684,88 +681,40 @@ impl MetaUi {
.text_color(text_dim) .text_color(text_dim)
.text_size(px(10.)) .text_size(px(10.))
.child( .child(
"Nota: en este MVP, los inputs todavía no aceptan teclado. \ "Tip: click en el campo para enfocar; Enter no envía (todavía), \
El submit usa los `default` del schema o vacío (campo opcional). \ usá el botón. Backspace borra el último carácter.",
Próximo iter: integración con yahweh-widget-text-input.",
), ),
); );
main main
} }
} }
fn cx_handler_view(
mod_idx: usize,
view_key: String,
) -> impl Fn(&ClickEvent, &mut Window, &mut App) + 'static {
let _ = (mod_idx, &view_key);
move |_e, _w, _cx| {
// GPUI handlers necesitan acceder al modelo de la entity actual;
// wirearemos via cx.update en el render real cuando el iter de
// eventos tipados esté listo. Por ahora el menu se navega via
// env var/restart.
}
}
fn action_button(
_cx: &mut Context<MetaUi>,
id: String,
label: String,
_action: Action,
accent: gpui::Rgba,
) -> gpui::Stateful<gpui::Div> {
div()
.id(SharedString::from(id))
.px(px(10.))
.py(px(4.))
.bg(rgb(0x232a36))
.text_color(accent)
.text_size(px(11.))
.rounded(px(3.))
.child(label)
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
#[test] #[test]
fn parse_field_text_returns_string() { fn parse_field_text_returns_string() {
let v = parse_field_value(FieldKind::Text, "hola").unwrap(); assert_eq!(parse_field_value(FieldKind::Text, "hola").unwrap(), json!("hola"));
assert_eq!(v, json!("hola"));
} }
#[test] #[test]
fn parse_field_number_int_then_float() { fn parse_field_number_int_then_float() {
let i = parse_field_value(FieldKind::Number, "42").unwrap(); assert_eq!(parse_field_value(FieldKind::Number, "42").unwrap(), json!(42));
assert_eq!(i, json!(42)); assert_eq!(parse_field_value(FieldKind::Number, "3.14").unwrap(), json!(3.14));
let f = parse_field_value(FieldKind::Number, "3.14").unwrap();
assert_eq!(f, json!(3.14));
} }
#[test] #[test]
fn parse_field_number_invalid_errors() { fn parse_field_number_invalid_errors() {
let r = parse_field_value(FieldKind::Number, "not-a-number"); assert!(parse_field_value(FieldKind::Number, "not-a-number").is_err());
assert!(r.is_err());
} }
#[test] #[test]
fn parse_field_boolean_variants() { fn parse_field_boolean_variants() {
assert_eq!( assert_eq!(parse_field_value(FieldKind::Boolean, "true").unwrap(), json!(true));
parse_field_value(FieldKind::Boolean, "true").unwrap(), assert_eq!(parse_field_value(FieldKind::Boolean, "yes").unwrap(), json!(true));
json!(true) assert_eq!(parse_field_value(FieldKind::Boolean, "false").unwrap(), json!(false));
); assert_eq!(parse_field_value(FieldKind::Boolean, "").unwrap(), json!(false));
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()); assert!(parse_field_value(FieldKind::Boolean, "maybe").is_err());
} }
@@ -776,10 +725,7 @@ mod tests {
"address": { "city": "Bogotá", "country": "CO" } "address": { "city": "Bogotá", "country": "CO" }
}); });
assert_eq!(lookup_field(&v, "name").unwrap(), &json!("Acme")); assert_eq!(lookup_field(&v, "name").unwrap(), &json!("Acme"));
assert_eq!( assert_eq!(lookup_field(&v, "address.city").unwrap(), &json!("Bogotá"));
lookup_field(&v, "address.city").unwrap(),
&json!("Bogotá")
);
assert!(lookup_field(&v, "missing").is_none()); assert!(lookup_field(&v, "missing").is_none());
assert!(lookup_field(&v, "address.zipcode").is_none()); assert!(lookup_field(&v, "address.zipcode").is_none());
} }