5d584ff815
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
743 lines
25 KiB
Rust
743 lines
25 KiB
Rust
//! `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<Module>,
|
|
/// Store compartido. Mutado por el submit de los forms.
|
|
store: Arc<Mutex<MemoryStore>>,
|
|
/// (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<String, Entity<TextInput>>,
|
|
/// Mensaje toast al pie (success de submit, error de carga, etc.).
|
|
toast: Option<SharedString>,
|
|
/// Si la carga de módulos falló al inicio.
|
|
load_error: Option<SharedString>,
|
|
}
|
|
|
|
impl MetaUi {
|
|
fn new(_cx: &mut Context<Self>) -> 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>) {
|
|
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<Self>) {
|
|
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<Self>,
|
|
) -> Result<Uuid, String> {
|
|
let module = &self.modules[mod_idx];
|
|
let spec_fields: Vec<FieldSpec> = 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<Value, String> {
|
|
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::<i64>() {
|
|
Ok(json!(i))
|
|
} else if let Ok(f) = raw.parse::<f64>() {
|
|
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<Self>) -> 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<Self>,
|
|
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<Self>,
|
|
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<Self>,
|
|
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::<f32>().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<Self>,
|
|
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");
|
|
}
|
|
}
|