From 5d584ff815dae762fe178f40eebf08df6fbb90b9 Mon Sep 17 00:00:00 2001 From: Sergio Date: Sat, 9 May 2026 20:11:33 +0000 Subject: [PATCH] feat(nakui-ui): inputs reales + click handlers funcionales MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 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 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 --- CHANGELOG.md | 61 +++++ crates/apps/nakui-ui/Cargo.toml | 2 + crates/apps/nakui-ui/src/main.rs | 404 +++++++++++++------------------ 3 files changed, 238 insertions(+), 229 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1d3d4be..826c116 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,67 @@ ratio/diff ver `git show `. ## 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` (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` 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 Salto cualitativo: Nakui pasa de "library + demos + read-only viewer del event log" a **plataforma ERP con UI dirigida por datos**. Cada diff --git a/crates/apps/nakui-ui/Cargo.toml b/crates/apps/nakui-ui/Cargo.toml index 409606d..9f6c82f 100644 --- a/crates/apps/nakui-ui/Cargo.toml +++ b/crates/apps/nakui-ui/Cargo.toml @@ -8,6 +8,8 @@ description = "Nakui — runtime GPUI de la metainterfaz: carga module.json desd [dependencies] nakui-core = { path = "../../modules/nakui/core" } 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 } serde_json = { workspace = true } uuid = { workspace = true, features = ["serde"] } diff --git a/crates/apps/nakui-ui/src/main.rs b/crates/apps/nakui-ui/src/main.rs index 406dd15..0fe4fc1 100644 --- a/crates/apps/nakui-ui/src/main.rs +++ b/crates/apps/nakui-ui/src/main.rs @@ -4,11 +4,12 @@ //! `module.json`), monta sidebar con sus menús, y renderea la vista //! activa en el panel principal: //! -//! - **List**: tabla de instancias de la entity. Botones de acción -//! en el header (típicamente "Nuevo" → form). -//! - **Form**: campos editables; al submit, escribe al `MemoryStore` -//! in-process via `seed_and_log` (alta directa) o por morphism -//! (TODO en este iter). +//! - **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 @@ -21,22 +22,29 @@ //! # 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, rgb, App, Application, Bounds, ClickEvent, Context, IntoElement, Render, - SharedString, Window, WindowBounds, WindowOptions, + 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, Column, FieldKind, FieldSpec, FormView, ListView, MenuItem, Module, View, + 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 { @@ -60,16 +68,14 @@ struct MetaUi { modules: Vec, /// Store compartido. Mutado por el submit de los forms. store: Arc>, - /// Módulo + vista actualmente seleccionados (índices a `modules` - /// y key dentro de `views` respectivamente). + /// (módulo idx, vista key) actualmente activos. active: Option<(usize, String)>, - /// Buffer del form actual: nombre del campo → valor texto. Se - /// resetea al cambiar de vista. - form_buffer: std::collections::BTreeMap, + /// 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, lo guardamos para - /// mostrarlo como banner de error permanente. + /// Si la carga de módulos falló al inicio. load_error: Option, } @@ -92,7 +98,6 @@ impl MetaUi { ), }; - // Auto-seleccionar la primera vista del primer módulo si hay. let active = modules .first() .and_then(|m| m.menu.first().map(|item| (0usize, item.view.clone()))); @@ -101,62 +106,81 @@ impl MetaUi { modules, store: Arc::new(Mutex::new(MemoryStore::new())), active, - form_buffer: Default::default(), + form_inputs: BTreeMap::new(), toast: None, load_error, } } - fn select_view(&mut self, mod_idx: usize, view_key: String) { - self.active = Some((mod_idx, view_key)); - self.form_buffer.clear(); + /// 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) { + 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.clone()); + self.select_view(mod_idx, view, cx); } Action::SeedEntity { entity, next_view } => { - match self.commit_seed(mod_idx, entity) { + 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.clone()); + self.select_view(mod_idx, v, cx); } 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) => { self.toast = Some(SharedString::from(format!("error: {e}"))); } } + cx.notify(); } 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!( "morphism '{name}': pendiente (requiere manifest nakui)" ))); + cx.notify(); } } } - /// Construye un Value desde el form buffer y lo seedea al store. - fn commit_seed(&mut self, mod_idx: usize, entity: &str) -> Result { + /// 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]; - // Recoge la spec del FormView activo para conocer field kinds. 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(), @@ -166,7 +190,11 @@ impl MetaUi { }; let mut obj = serde_json::Map::new(); 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() { return Err(format!("campo '{}' es obligatorio", f.label)); } @@ -186,9 +214,7 @@ impl MetaUi { } } - /// Snapshot ordenado de records de una entity (entity → rows). - /// Materializa a Vec antes de soltar el lock — el iterator del - /// Store traer un borrow que no sobrevive al drop del guard. + /// 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, @@ -198,11 +224,9 @@ impl MetaUi { Ok(i) => i, Err(_) => return Vec::new(), }; - let out: Vec<(Uuid, Value)> = it - .filter(|(e, _, _)| e == entity) + it.filter(|(e, _, _)| e == entity) .map(|(_, id, v)| (id, v)) - .collect(); - out + .collect() } } @@ -226,8 +250,6 @@ fn parse_field_value(kind: FieldKind, raw: &str) -> Result { } } -/// 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> { let mut cur = v; for seg in path.split('.') { @@ -252,13 +274,13 @@ fn short_uuid(id: &Uuid) -> String { impl Render for MetaUi { fn render(&mut self, _w: &mut Window, cx: &mut Context) -> impl IntoElement { - let bg = rgb(0x14171c); - let panel = rgb(0x1d2128); - let border = rgb(0x2a2f38); - let text = rgb(0xe6e8ec); - let text_dim = rgb(0x9ba1ad); - let accent = rgb(0x88c0d0); - let accent_active = rgb(0xa3be8c); + 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); @@ -266,8 +288,8 @@ impl Render for MetaUi { div() .px(px(12.)) .py(px(6.)) - .bg(rgb(0x2d3a2a)) - .text_color(rgb(0xc0e0a0)) + .bg(gpui::rgb(0x2d3a2a)) + .text_color(gpui::rgb(0xc0e0a0)) .text_size(px(11.)) .child(t.clone()) }); @@ -275,8 +297,8 @@ impl Render for MetaUi { div() .px(px(12.)) .py(px(6.)) - .bg(rgb(0x4a2020)) - .text_color(rgb(0xffd0d0)) + .bg(gpui::rgb(0x4a2020)) + .text_color(gpui::rgb(0xffd0d0)) .text_size(px(11.)) .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() { sidebar = sidebar.child( div() @@ -351,8 +376,7 @@ impl MetaUi { ); for item in &m.menu { - let is_active = self - .active + let is_active = active_snapshot .as_ref() .map(|(i, v)| *i == mod_idx && v == &item.view) .unwrap_or(false); @@ -362,58 +386,33 @@ impl MetaUi { .map(|ic| format!("{ic} {}", item.label)) .unwrap_or_else(|| item.label.clone()); + let view_key = item.view.clone(); sidebar = sidebar.child( - self.menu_item_button( - cx, - mod_idx, - item.view.clone(), - label, - is_active, - text, - text_dim, - accent_active, - ), + 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 } - #[allow(clippy::too_many_arguments)] - fn menu_item_button( - &self, - _cx: &mut Context, - mod_idx: usize, - view_key: String, - label: String, - is_active: bool, - text: gpui::Rgba, - text_dim: gpui::Rgba, - accent: gpui::Rgba, - ) -> gpui::Stateful { - 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( - &self, + &mut self, cx: &mut Context, panel: gpui::Rgba, border: gpui::Rgba, @@ -440,24 +439,24 @@ impl MetaUi { } }; - let module = match self.modules.get(mod_idx) { - Some(m) => m, - None => return main.child(div().text_color(text_dim).child("Módulo inválido")), - }; - let view = match module.views.get(&view_key) { - Some(v) => v, + 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), + 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), } } @@ -473,7 +472,6 @@ impl MetaUi { text_dim: gpui::Rgba, accent: gpui::Rgba, ) -> gpui::Div { - // Header con título + acciones. let mut header = div() .flex() .flex_row() @@ -494,20 +492,30 @@ impl MetaUi { Action::SeedEntity { entity, .. } => format!("Seed {entity}"), Action::Morphism { name, .. } => format!("⚡ {name}"), }; - header = header.child(action_button( - cx, - format!("list-action-{mod_idx}-{idx}"), - label, - action.clone(), - accent, - )); + 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(); - // Header de columnas. let total_weight: f32 = lv.columns.iter().map(|c| c.weight).sum::().max(0.01); let mut col_header = div() .flex() @@ -526,22 +534,16 @@ impl MetaUi { .child(c.label.clone()), ); } - col_header = col_header.child( - div() - .w(px(80.)) - .text_color(text_dim) - .child("id"), - ); + col_header = col_header.child(div().w(px(80.)).text_color(text_dim).child("id")); main = main.child(col_header); - // Filas. for (id, value) in &rows { let mut row = div() .flex() .flex_row() .py(px(6.)) .border_b_1() - .border_color(rgb(0x232a36)) + .border_color(gpui::rgb(0x232a36)) .text_color(text) .text_size(px(12.)); for c in &lv.columns { @@ -592,12 +594,11 @@ impl MetaUi { mut main: gpui::Div, fv: &FormView, mod_idx: usize, - border: gpui::Rgba, + _border: gpui::Rgba, text: gpui::Rgba, text_dim: gpui::Rgba, accent: gpui::Rgba, ) -> gpui::Div { - let _ = border; main = main.child( div() .text_color(text) @@ -606,49 +607,36 @@ impl MetaUi { .child(fv.title.clone()), ); 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 { 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), - ) - .child( - // GPUI no incluye un text_input nativo; mostramos - // el buffer actual como texto. Para entrada - // teclado real, integrar yahweh-widget-text-input - // (próxima iteración). Por ahora el form sirve - // demos visuales y el seed via API programática. + + 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(rgb(0x171a20)) - .border_1() - .border_color(rgb(0x2a2f38)) - .rounded(px(3.)) - .text_color(text) - .text_size(px(12.)) - .child(if display.is_empty() { - "(vacío — input GPUI pendiente)".to_string() - } else { - display - }), + .bg(gpui::rgb(0x171a20)) + .text_color(text_dim) + .child("(input no inicializado)"), ); + } + if let Some(help) = &f.help { field_box = field_box.child( div() @@ -668,14 +656,23 @@ impl MetaUi { label.clone().unwrap_or_else(|| format!("Ir a {view}")) } }; + let submit_action = fv.on_submit.clone(); main = main.child( - div().mt(px(12.)).child(action_button( - cx, - format!("form-submit-{mod_idx}"), - submit_label, - fv.on_submit.clone(), - accent, - )), + 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( @@ -684,88 +681,40 @@ impl MetaUi { .text_color(text_dim) .text_size(px(10.)) .child( - "Nota: en este MVP, los inputs todavía no aceptan teclado. \ - El submit usa los `default` del schema o vacío (campo opcional). \ - Próximo iter: integración con yahweh-widget-text-input.", + "Tip: click en el campo para enfocar; Enter no envía (todavía), \ + usá el botón. Backspace borra el último carácter.", ), ); 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, - id: String, - label: String, - _action: Action, - accent: gpui::Rgba, -) -> gpui::Stateful { - 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)] mod tests { use super::*; #[test] fn parse_field_text_returns_string() { - let v = parse_field_value(FieldKind::Text, "hola").unwrap(); - assert_eq!(v, json!("hola")); + assert_eq!(parse_field_value(FieldKind::Text, "hola").unwrap(), json!("hola")); } #[test] fn parse_field_number_int_then_float() { - let i = parse_field_value(FieldKind::Number, "42").unwrap(); - assert_eq!(i, json!(42)); - let f = parse_field_value(FieldKind::Number, "3.14").unwrap(); - assert_eq!(f, json!(3.14)); + 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() { - let r = parse_field_value(FieldKind::Number, "not-a-number"); - assert!(r.is_err()); + 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_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()); } @@ -776,10 +725,7 @@ mod tests { "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_eq!(lookup_field(&v, "address.city").unwrap(), &json!("Bogotá")); assert!(lookup_field(&v, "missing").is_none()); assert!(lookup_field(&v, "address.zipcode").is_none()); }