refactor(monorepo): reorganización lógica + renames + SDDs + split CHANGELOG

Reorganización física de crates/:
- core/ (mezclaba 6 propósitos) se divide en protocol/, init/, runtime/, compat/
- shared/ (3 crates) se redistribuye en protocol/ e init/
- lapaloma (sub-módulo de ui_engine) se promueve a modules/pineal/

Renames de proyectos:
- shipote → shuma (runtime de sandboxes)
- nouser → akasha (explorador de Mónadas)
- yahweh → nahual (motor GPUI, antes ui_engine/)
- lapaloma → pineal (data-viz agnóstica)

Fraccionamiento UI → core agnóstico:
- vista-core (DeckState + snap, 175 LOC, 5 tests verdes)
- barra-core (Task + render_html + sanitize, 90 LOC, 5 tests verdes)
- vista-web y barra-web ahora son thin DOM bindings

Documentación nueva:
- 16 SDDs por subdirectorio (≤80 LOC c/u): protocol/init/runtime/compat
  + 10 módulos + apps/
- docs/STATUS.md con cifras reales por proyecto
- docs/ROADMAP.md con plan a finalización (6 hitos, ~6-8 semanas)
- CHANGELOG.md particionado en docs/changelog/<proyecto>.md (7 buckets)

Automatización:
- scripts/reorg.py — script idempotente que: git mv directorios, renombra
  package names, recomputa path = refs, reescribe imports rust, actualiza
  workspace Cargo.toml. Soporta --dry-run.
- scripts/split-changelog.py — particiona CHANGELOG por componente.

Validación:
- cargo check --workspace pasa (124 crates + 2 nuevos cores).
- 10 tests adicionales (5 en vista-core + 5 en barra-core) verdes.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
sergio
2026-05-19 14:48:34 +00:00
parent 86fb6ae20b
commit 550c98f275
375 changed files with 8512 additions and 7155 deletions
+39
View File
@@ -0,0 +1,39 @@
# modules/nahual/ — Motor de UI GPUI (era yahweh)
**Propósito.** Framework de widgets sobre GPUI para apps de escritorio
nativas: theme persistente, meta-runtime declarativo, providers de
datos (fs+sqlite), bus de eventos, launcher de paneles.
## Estructura
```
nahual/
libs/ — núcleo del framework
core, theme, launcher, bus,
meta-schema, meta-runtime,
providers/{fs, sqlite}
widgets/ — widgets reutilizables
tree, container_core, splitter, tabs, tiled,
text_input, meta-form, banner, card,
stat-card, app-header, theme-switcher
```
## Dependencias
- Todos los crates `libs/*` y `widgets/*``gpui` (acoplo intencional).
- `theme``directories` (persistencia de preferencias).
- `meta-runtime` evalúa esquemas Nickel.
- Consumido por: `apps/nahual-*` (file/db/text/image explorer + shell)
y `apps/cosmobiologia` (vía cosmobiologia-canvas/panel/tree),
`apps/akasha-explorer`, `apps/nakui-*`, `apps/minga-explorer`.
## Patrón estándar de explorer
`nahual-shell` define el shell standard (sidebar + main + status panel
+ hot-reload). Cada app explorer la encarna con su backend custom.
## Estado
LOC 15,968 (sin contar pineal). Tests E2E con `gpui::TestAppContext`.
Maduro y estable; backbone visual del monorepo. Ver
`docs/changelog/nahual.md`.
@@ -0,0 +1,9 @@
[package]
name = "nahual-bus"
version = { workspace = true }
edition = { workspace = true }
license = { workspace = true }
description = "AppBus + AppEvent — comunicación cross-widget app-level."
[dependencies]
gpui = { workspace = true }
+44
View File
@@ -0,0 +1,44 @@
//! `nahual_bus` — `AppBus` + `AppEvent` para comunicación cross-widget.
//!
//! Es un `Entity<AppBus>` que emite [`AppEvent`]. Cualquier widget se
//! subscribe con `cx.subscribe(&bus, |this, _, ev, cx| { ... })`. La
//! Shell crea exactamente un AppBus al boot y lo distribuye:
//!
//! - **Productores** (FileExplorer, DatabaseExplorer): el LayoutHost los
//! subscribe individualmente y reenvía sus eventos tipados al bus,
//! normalizando al formato `{provider, id, …}` agnóstico.
//! - **Consumidores** (TextViewer, ImageViewer, …): reciben el handle del
//! bus en su constructor y se subscriben directo.
//!
//! Por qué un bus y no `cx.subscribe` directo entre productor y consumidor:
//! los viewers no saben qué explorers existen (ni viceversa). El bus
//! desacopla — puede haber 0, 1 o N explorers de distintos providers, y
//! varios viewers en paralelo viendo el mismo evento.
use gpui::EventEmitter;
/// Eventos cross-widget. Diseñados para ser agnósticos del dominio:
/// `provider` es el id (string) del DataProvider que sabe interpretar el
/// `id`. `provider_path` es el contexto opcional (ej. el .sqlite del
/// DatabaseExplorer) que el viewer necesita para construir su provider.
#[derive(Clone, Debug)]
pub enum AppEvent {
/// Una entidad fue seleccionada (single click). Suele triggerear un
/// preview en el viewer activo.
EntitySelected {
provider: String,
provider_path: Option<String>,
id: String,
},
/// Una entidad fue ejecutada (doble click u "Open" del menú).
EntityOpened {
provider: String,
provider_path: Option<String>,
id: String,
},
}
#[derive(Default)]
pub struct AppBus;
impl EventEmitter<AppEvent> for AppBus {}
@@ -0,0 +1,12 @@
[package]
name = "nahual-core"
version = { workspace = true }
edition = { workspace = true }
license = { workspace = true }
description = "Tipos data compartidos: providers, layout JSON, taxonomía de módulos."
[dependencies]
async-trait = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
tokio = { workspace = true }
+266
View File
@@ -0,0 +1,266 @@
//! `nahual_core` — tipos compartidos por toda la app, sin dependencias de UI.
//!
//! Contiene tres bloques:
//! 1. **Providers** (`DataProvider`, `EntityNode`, `DisplayType`) — fuente de
//! datos jerárquicos para los exploradores. Portado intacto de `gioser_core`.
//! 2. **Layout** (`LayerConfig`, `LayerParam`, `ModuloTipo`, `LayoutDirection`,
//! `LayoutMode`) — el JSON describe el árbol de widgets. Portado de
//! `gioser_core` quitando los helpers acoplados a Makepad (`LiveId`).
//! 3. **Identidad** (`NodeId`) — id estable de un nodo del layout, derivado
//! del `id` JSON o del path estructural.
//!
//! NO contiene tipos de comunicación entre widgets. Esos viven en la `shell`
//! y se construyen sobre el sistema de eventos de GPUI (`EventEmitter`,
//! `cx.subscribe`).
use async_trait::async_trait;
use serde::{Deserialize, Serialize};
use std::pin::Pin;
use tokio::io::{AsyncRead, AsyncWrite};
// =====================================================================
// Providers
// =====================================================================
#[derive(Clone, Debug)]
pub enum DisplayType {
Folder,
File,
Stream,
}
#[derive(Clone, Debug)]
pub struct EntityNode {
pub id: String,
pub name: String,
pub display_type: DisplayType,
pub mime_type: Option<String>,
}
#[async_trait]
pub trait DataProvider: Send + Sync {
fn provider_id(&self) -> String;
async fn list_children(&self, parent_id: Option<&str>) -> Result<Vec<EntityNode>, String>;
async fn get_read_stream(
&self,
entity_id: &str,
) -> Result<Pin<Box<dyn AsyncRead + Send>>, String>;
async fn get_write_stream(
&self,
entity_id: &str,
) -> Result<Pin<Box<dyn AsyncWrite + Send>>, String>;
/// Default convenience: vacía un read stream a `Vec<u8>`. Los providers
/// pueden override si tienen un fast-path.
async fn get_data(&self, entity_id: &str) -> Result<Vec<u8>, String> {
use tokio::io::AsyncReadExt;
let mut stream = self.get_read_stream(entity_id).await?;
let mut buffer = Vec::new();
stream
.read_to_end(&mut buffer)
.await
.map_err(|e| e.to_string())?;
Ok(buffer)
}
}
// =====================================================================
// Identidad estable de nodos del layout
// =====================================================================
/// Identificador estable de un nodo del árbol de layout. Construido con
/// `NodeId::from_layer(&LayerConfig, path)` durante el DFS del LayoutHost:
/// si el `LayerConfig` trae `id` propio, se usa ese; si no, se sintetiza a
/// partir del path estructural (`root/main/0`).
///
/// Internamente es una `String` para no atarse al sistema de hashing de
/// ningún framework. La igualdad lexicográfica garantiza estabilidad: el
/// mismo `id` o el mismo path producen el mismo `NodeId`.
#[derive(Clone, Debug, Hash, PartialEq, Eq, Serialize, Deserialize)]
pub struct NodeId(pub String);
impl NodeId {
pub fn new(s: impl Into<String>) -> Self {
Self(s.into())
}
pub fn from_layer(cfg: &LayerConfig, path: &str) -> Self {
match &cfg.id {
Some(id) if !id.is_empty() => Self(id.clone()),
_ => Self(path.to_string()),
}
}
pub fn as_str(&self) -> &str {
&self.0
}
}
impl std::fmt::Display for NodeId {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.0)
}
}
// =====================================================================
// Taxonomía y layout JSON
// =====================================================================
/// Tipo de módulo que la Shell sabe instanciar. Cualquier `kind` del JSON se
/// resuelve a uno de estos via `ModuloTipo::from_kind`.
#[derive(Copy, Clone, Debug, PartialEq, Eq)]
pub enum ModuloTipo {
Texto,
Arbol,
Imagen,
/// Marco contenedor — define un sub-layout y aloja hijos.
Contenedor,
/// Tile manager autónomo (Tiled / Floating / Stacked + shortcuts).
TileManager,
}
impl ModuloTipo {
pub fn from_kind(kind: &str) -> Self {
match kind {
"TextViewer" | "SectionEditor" | "Texto" => Self::Texto,
"FileExplorer" | "DatabaseExplorer" | "Arbol" => Self::Arbol,
"ImageViewer" | "Imagen" => Self::Imagen,
"TileManager" | "Tiled" => Self::TileManager,
_ => Self::Contenedor,
}
}
}
#[derive(Copy, Clone, Debug, PartialEq, Eq)]
pub enum LayoutDirection {
Horizontal,
Vertical,
Overlay,
}
impl Default for LayoutDirection {
fn default() -> Self {
Self::Vertical
}
}
impl LayoutDirection {
pub fn from_str(s: &str) -> Self {
match s {
"horizontal" | "Horizontal" | "row" => Self::Horizontal,
"overlay" | "Overlay" | "stack" => Self::Overlay,
_ => Self::Vertical,
}
}
}
/// Política global del root: cómo se presentan los hijos directos del
/// `LayerConfig` raíz entre sí (Tiled / Stacked / Floating). Distinta de
/// `LayoutDirection` que es por contenedor.
#[derive(Copy, Clone, Debug, PartialEq, Eq)]
pub enum LayoutMode {
Tiled,
Stacked,
Floating,
}
impl Default for LayoutMode {
fn default() -> Self {
Self::Tiled
}
}
impl LayoutMode {
pub fn from_str(s: &str) -> Self {
match s {
"stacked" | "Stacked" => Self::Stacked,
"floating" | "Floating" => Self::Floating,
_ => Self::Tiled,
}
}
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct LayerParam {
pub key: String,
pub value: String,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct LayerConfig {
/// Identificador estable. Si falta, el LayoutHost sintetiza desde el path.
pub id: Option<String>,
/// Nombre de clase del módulo (e.g. "FileExplorer", "Split", "Tabs").
pub kind: String,
/// Peso flex relativo entre hermanos. `None` ⇒ 1.0.
pub flex: Option<f64>,
/// Solo válido para contenedores con orientación. `None` ⇒ Vertical.
pub direction: Option<String>,
pub params: Vec<LayerParam>,
pub children: Vec<LayerConfig>,
}
impl Default for LayerConfig {
fn default() -> Self {
Self {
id: Some("root".to_string()),
kind: "Split".to_string(),
flex: Some(1.0),
direction: Some("horizontal".to_string()),
params: vec![],
children: vec![
LayerConfig {
id: Some("nav".to_string()),
kind: "FileExplorer".to_string(),
flex: Some(0.3),
direction: None,
params: vec![],
children: vec![],
},
LayerConfig {
id: Some("main".to_string()),
kind: "TextViewer".to_string(),
flex: Some(0.7),
direction: None,
params: vec![],
children: vec![],
},
],
}
}
}
impl LayerConfig {
pub fn load_or_default(path: &str) -> Self {
std::fs::read_to_string(path)
.ok()
.and_then(|json| serde_json::from_str(&json).ok())
.unwrap_or_default()
}
pub fn serialize_json(&self) -> String {
serde_json::to_string_pretty(self).unwrap_or_default()
}
pub fn get_param(&self, key: &str) -> Option<&String> {
self.params.iter().find(|p| p.key == key).map(|p| &p.value)
}
pub fn flex_weight(&self) -> f64 {
self.flex.unwrap_or(1.0).max(0.0)
}
pub fn modulo_tipo(&self) -> ModuloTipo {
ModuloTipo::from_kind(&self.kind)
}
pub fn layout_direction(&self) -> LayoutDirection {
self.direction
.as_deref()
.map(LayoutDirection::from_str)
.unwrap_or(LayoutDirection::Vertical)
}
}
@@ -0,0 +1,13 @@
[package]
name = "nahual-launcher"
version = { workspace = true }
edition = { workspace = true }
license = { workspace = true }
description = "Launcher GPUI reusable: Application::new + Theme::install_default + cx.open_window + cx.activate. Las explorer apps lo invocan en una sola línea (`launch_app(title, size, root_factory)`)."
[dependencies]
gpui = { workspace = true }
nahual-theme = { path = "../theme" }
[dev-dependencies]
gpui = { workspace = true, features = ["test-support"] }
@@ -0,0 +1,123 @@
//! Yahweh shell — reduce el boot de un app GPUI temed a una línea.
//!
//! Las 4 (próximamente más) apps explorer del repo declaran el mismo
//! patrón: `Application::new + Theme::install_default + cx.open_window
//! + cx.activate(true)`. Sólo varían el título, el tamaño inicial y la
//! fábrica del root entity.
//!
//! Antes (~20 líneas):
//!
//! ```ignore
//! Application::new().run(|cx: &mut App| {
//! Theme::install_default(cx);
//! let bounds = Bounds::centered(None, gpui::size(px(900.), px(640.)), cx);
//! cx.open_window(
//! WindowOptions {
//! window_bounds: Some(WindowBounds::Windowed(bounds)),
//! titlebar: Some(gpui::TitlebarOptions {
//! title: Some(SharedString::from("Nakui — Event Log")),
//! ..Default::default()
//! }),
//! ..Default::default()
//! },
//! |_w, cx| cx.new(Explorer::new),
//! ).expect("open window");
//! cx.activate(true);
//! });
//! ```
//!
//! Ahora (1 línea):
//!
//! ```ignore
//! launch_app("Nakui — Event Log", (900., 640.), Explorer::new);
//! ```
use gpui::{
App, AppContext, Application, Bounds, Context, Render, SharedString, TitlebarOptions,
WindowBounds, WindowOptions, px,
};
use nahual_theme::Theme;
/// Configuración del primer (y normalmente único) ventana del app.
///
/// `size` es `(ancho, alto)` en píxeles lógicos. La ventana queda
/// centrada en el monitor primario.
pub struct AppLaunchConfig {
pub title: SharedString,
pub size: (f32, f32),
}
impl AppLaunchConfig {
pub fn new(title: impl Into<SharedString>, size: (f32, f32)) -> Self {
Self {
title: title.into(),
size,
}
}
}
/// Levanta un app GPUI con tema instalado y root entity construido.
///
/// El root debe implementar `Render`. La fábrica `root_factory` recibe
/// el `Context<T>` del nuevo entity para que pueda usar `cx.spawn`,
/// suscribirse a eventos, etc — lo mismo que en el patrón directo.
///
/// Bloquea el thread main hasta que se cierre la ventana
/// (`Application::run` no retorna).
pub fn launch_app<T, F>(title: impl Into<SharedString>, size: (f32, f32), root_factory: F)
where
T: Render + 'static,
F: FnOnce(&mut Context<T>) -> T + Send + 'static,
{
launch_app_with(AppLaunchConfig::new(title, size), root_factory);
}
/// Variante que acepta un `AppLaunchConfig` armado afuera. Útil cuando
/// el config se calcula condicionalmente (env var para tamaño, etc).
pub fn launch_app_with<T, F>(config: AppLaunchConfig, root_factory: F)
where
T: Render + 'static,
F: FnOnce(&mut Context<T>) -> T + Send + 'static,
{
Application::new().run(move |cx: &mut App| {
Theme::install_default(cx);
let bounds = Bounds::centered(None, gpui::size(px(config.size.0), px(config.size.1)), cx);
cx.open_window(
WindowOptions {
window_bounds: Some(WindowBounds::Windowed(bounds)),
titlebar: Some(TitlebarOptions {
title: Some(config.title.clone()),
..Default::default()
}),
..Default::default()
},
|_w, cx| cx.new(root_factory),
)
.expect("open window");
cx.activate(true);
});
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn config_new_normalizes_inputs() {
let c = AppLaunchConfig::new("My App", (800.0, 600.0));
assert_eq!(c.title.as_ref(), "My App");
assert_eq!(c.size, (800.0, 600.0));
}
#[test]
fn config_accepts_owned_string_title() {
let owned = String::from("Owned Title");
let c = AppLaunchConfig::new(owned, (400.0, 300.0));
assert_eq!(c.title.as_ref(), "Owned Title");
}
// No hay test de `launch_app` aquí: bloquea el thread main hasta
// que la ventana se cierre, y en sandbox no hay DISPLAY. La
// cobertura real es que cada explorer app lo invoque y arranque
// (smoke test manual o con DISPLAY).
}
@@ -0,0 +1,12 @@
[package]
name = "nahual-meta-runtime"
version.workspace = true
edition.workspace = true
license.workspace = true
description = "Yahweh — meta-runtime: helpers puros (parse, delta, validación, formato) que cualquier widget metainterfaz consume sobre `yahweh-meta-schema`. Sin GPUI, sin backend específico — toma cierres/closures para acceder al store."
[dependencies]
serde_json = { workspace = true }
thiserror = { workspace = true }
uuid = { workspace = true, features = ["serde"] }
nahual-meta-schema = { path = "../meta-schema" }
@@ -0,0 +1,279 @@
//! `MetaBackend` trait — la frontera entre el widget metainterfaz
//! (nahual) y la implementación concreta de persistencia/ejecución
//! (nakui-core, Surreal, mocks para tests).
//!
//! El widget consume este trait; el binario lo implementa con su
//! stack particular. Esto es lo que hace que el widget sea reusable.
//!
//! Convenciones documentadas en el doc del trait abajo.
use std::collections::BTreeMap;
use serde_json::Value;
use uuid::Uuid;
/// Resultado uniforme de una operación de escritura del backend.
///
/// La UI lo usa para componer el toast: `id` para mostrar el
/// short_uuid, `changed` para diferenciar "actualizado X (3 campos)"
/// vs "sin cambios", `post_status` para concatenar mensajes
/// emitidos por hooks internos del backend (ej. "auto-compact:
/// snapshot @ seq 49") sin que la UI tenga que conocer el detalle.
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct WriteOutcome {
/// Id del record afectado. `Some` para seed/update/delete;
/// `None` para morphism cuando afecta múltiples records.
pub id: Option<Uuid>,
/// Cantidad de cambios efectivos. `0` = no-op (edit que no
/// modificó ningún campo, etc.).
pub changed: usize,
/// Mensaje de status opcional para concatenar al toast del op
/// original con el separator estándar.
pub post_status: Option<String>,
}
impl WriteOutcome {
/// Constructor para no-op writes (edits sin cambios).
pub fn no_change(id: Uuid) -> Self {
Self {
id: Some(id),
changed: 0,
post_status: None,
}
}
}
/// Backend que un widget de metainterfaz usa para leer y mutar
/// records. Decoupla el widget (nahual) de la implementación
/// concreta (nakui-core, Surreal, mock para tests).
///
/// # Convención sobre ids
///
/// `Uuid` canónico. Backends que internamente usan otros tipos
/// deben mapear via Uuid (hash determinista, wrapper, lo que sirva).
/// Esto evita generic associated types que complicarían el dispatch
/// en `cx.listener` de GPUI.
///
/// # Convención sobre validación
///
/// El backend ES la fuente de verdad sobre invariantes (KCL/Nickel
/// post-checks, conservación, etc.). El widget pre-valida shape
/// (nahual-meta-runtime: `parse_field_value`, `validate_entity_refs`)
/// pero el backend puede rebotar con `Err(...)` si su validación
/// adicional falla — el widget muestra el error al usuario.
///
/// # Convención sobre threading
///
/// `'static` (no `Send + Sync`): el widget vive en `Entity<MetaApp<B>>`
/// que requiere `'static`, pero los handlers son single-threaded en
/// el main UI thread de GPUI. Si en el futuro un backend necesita
/// `cx.spawn`, agregamos los marker traits.
///
/// # Convención sobre delta computation
///
/// El widget pre-computa `set` y `clear` con
/// [`crate::delta::compute_field_delta`] +
/// [`crate::delta::compute_clear_fields`] *antes* de llamar a
/// [`MetaBackend::update`]. El backend no recomputa: si recibe ambos
/// vacíos devuelve `changed = 0` sin escribir nada. Esto evita
/// double-roundtrip al store por el mismo dato.
pub trait MetaBackend: 'static {
/// Snapshot ordenado de records de una entity.
/// Orden estable (lexicográfico por id) para UI determinista.
/// Vacío si no hay records.
fn list_records(&self, entity: &str) -> Vec<(Uuid, Value)>;
/// Lee un record por id. `None` si no existe.
fn load_record(&self, entity: &str, id: Uuid) -> Option<Value>;
/// Crea un record nuevo. El backend asigna el `Uuid`
/// (devuelve en `WriteOutcome.id`). `changed = 1` siempre.
fn seed(
&mut self,
entity: &str,
data: serde_json::Map<String, Value>,
) -> Result<WriteOutcome, String>;
/// Edita un record existente. Aplica `set` (overrides) y
/// `clear` (key removal). `changed = set.len() + clear.len()`.
/// Si ambos están vacíos (no-op edit), devuelve
/// `WriteOutcome::no_change(id)` sin error y sin escribir al log.
fn update(
&mut self,
entity: &str,
id: Uuid,
set: serde_json::Map<String, Value>,
clear: Vec<String>,
) -> Result<WriteOutcome, String>;
/// Borra un record. `changed = 1` si existía, error si no.
fn delete(&mut self, entity: &str, id: Uuid) -> Result<WriteOutcome, String>;
/// Ejecuta un morphism declarado por un módulo. El backend
/// resuelve la implementación, valida, computa ops, las aplica.
/// `changed = N ops aplicadas`.
///
/// `module_id` ubica al módulo (el trait no asume estructura del
/// manifest — el backend lo resuelve internamente).
fn morphism(
&mut self,
module_id: &str,
name: &str,
inputs: BTreeMap<String, Uuid>,
params: Value,
) -> Result<WriteOutcome, String>;
}
#[cfg(test)]
mod tests {
//! Tests del trait via [`crate::testing::MockBackend`]. Verifican
//! el contrato genérico (object-safety, semantica de seed/update/
//! delete) sin atar a un backend concreto. Los tests del mock en
//! sí (constructores, with_morphism, etc.) viven en
//! `crate::testing::tests`.
use super::*;
use crate::testing::MockBackend;
use serde_json::json;
fn map_of(items: &[(&str, Value)]) -> serde_json::Map<String, Value> {
items.iter().map(|(k, v)| (k.to_string(), v.clone())).collect()
}
#[test]
fn seed_then_load_round_trip() {
let mut b = MockBackend::new();
let out = b
.seed("Customer", map_of(&[("name", json!("Acme"))]))
.unwrap();
let id = out.id.expect("seed devuelve id");
assert_eq!(out.changed, 1);
assert!(out.post_status.is_none());
let rec = b.load_record("Customer", id).unwrap();
assert_eq!(rec.get("name"), Some(&json!("Acme")));
}
#[test]
fn list_records_filters_by_entity_and_orders_stably() {
let mut b = MockBackend::new();
let _ = b.seed("A", map_of(&[("k", json!(1))])).unwrap();
let _ = b.seed("B", map_of(&[("k", json!(2))])).unwrap();
let _ = b.seed("A", map_of(&[("k", json!(3))])).unwrap();
let a = b.list_records("A");
assert_eq!(a.len(), 2);
let b_only = b.list_records("B");
assert_eq!(b_only.len(), 1);
let none = b.list_records("Missing");
assert!(none.is_empty());
// Orden estable: re-llamadas devuelven mismo orden.
let a_again = b.list_records("A");
assert_eq!(
a.iter().map(|(id, _)| *id).collect::<Vec<_>>(),
a_again.iter().map(|(id, _)| *id).collect::<Vec<_>>(),
);
}
#[test]
fn update_with_set_changes_field() {
let mut b = MockBackend::new();
let id = b
.seed("Customer", map_of(&[("name", json!("Acme")), ("notes", json!("x"))]))
.unwrap()
.id
.unwrap();
let out = b
.update(
"Customer",
id,
map_of(&[("name", json!("Acme S.A."))]),
vec![],
)
.unwrap();
assert_eq!(out.changed, 1);
assert_eq!(out.id, Some(id));
let rec = b.load_record("Customer", id).unwrap();
assert_eq!(rec.get("name"), Some(&json!("Acme S.A.")));
assert_eq!(rec.get("notes"), Some(&json!("x")), "notes intacto");
}
#[test]
fn update_with_clear_removes_key() {
let mut b = MockBackend::new();
let id = b
.seed("Customer", map_of(&[("name", json!("Acme")), ("notes", json!("x"))]))
.unwrap()
.id
.unwrap();
let out = b
.update("Customer", id, serde_json::Map::new(), vec!["notes".into()])
.unwrap();
assert_eq!(out.changed, 1);
let rec = b.load_record("Customer", id).unwrap();
assert_eq!(rec.get("name"), Some(&json!("Acme")));
assert!(rec.get("notes").is_none(), "notes debería estar borrado");
}
#[test]
fn update_with_empty_set_and_clear_returns_no_change() {
let mut b = MockBackend::new();
let id = b
.seed("Customer", map_of(&[("name", json!("Acme"))]))
.unwrap()
.id
.unwrap();
let out = b
.update("Customer", id, serde_json::Map::new(), vec![])
.unwrap();
assert_eq!(out, WriteOutcome::no_change(id));
}
#[test]
fn update_on_missing_record_errors() {
let mut b = MockBackend::new();
let id = Uuid::new_v4();
let err = b
.update("Customer", id, map_of(&[("x", json!(1))]), vec![])
.unwrap_err();
assert!(err.contains("not found"));
}
#[test]
fn delete_removes_and_then_load_returns_none() {
let mut b = MockBackend::new();
let id = b
.seed("Customer", map_of(&[("name", json!("Acme"))]))
.unwrap()
.id
.unwrap();
let out = b.delete("Customer", id).unwrap();
assert_eq!(out.changed, 1);
assert_eq!(out.id, Some(id));
assert!(b.load_record("Customer", id).is_none());
}
#[test]
fn delete_on_missing_record_errors() {
let mut b = MockBackend::new();
let id = Uuid::new_v4();
assert!(b.delete("Customer", id).is_err());
}
/// Sanity: el trait acepta llamadas via `&mut dyn MetaBackend`
/// (object-safety). Esto permite que el widget tenga
/// `Box<dyn MetaBackend>` si el use case requiere borrado de
/// tipo (vs. el path normal con `MetaApp<B: MetaBackend>`).
#[test]
fn trait_is_object_safe() {
let mut b: Box<dyn MetaBackend> = Box::new(MockBackend::new());
let _ = b.seed("X", map_of(&[("k", json!(1))])).unwrap();
assert_eq!(b.list_records("X").len(), 1);
}
}
@@ -0,0 +1,134 @@
//! Cálculo del delta entre el record actual y la propuesta del form.
//!
//! Sirve a un runtime de edición para emitir SOLO los Set/Clear que
//! cambian algo: log + apply minimales, no-op edits = 0 entries.
use serde_json::Value;
/// Calcula el delta entre el record actual y los valores propuestos
/// del form. Devuelve un Map con sólo los campos cuyo valor difiere.
///
/// Comparación: igualdad estructural sobre `serde_json::Value`. Un
/// `current=Value::Null` (record no encontrado) hace que todos los
/// campos del `proposed` sean considerados nuevos. Un campo del
/// proposed que coincide con el del current se omite. Campos que
/// están en current pero NO en proposed se preservan tal cual (el
/// edit no los toca; ver [`compute_clear_fields`] para borrar
/// explícito desde un input vacío).
pub fn compute_field_delta(
current: &Value,
proposed: &serde_json::Map<String, Value>,
) -> serde_json::Map<String, Value> {
proposed
.iter()
.filter(|(field, value)| current.get(field.as_str()) != Some(*value))
.map(|(k, v)| (k.clone(), v.clone()))
.collect()
}
/// Decide cuáles fields del `to_clear` candidate list ameritan
/// realmente un `FieldOp::Clear`: sólo los que existen en el current
/// con un valor non-null. Para fields ausentes o ya null, Clear es
/// no-op semántico (el post-state es el mismo) y dropearlos
/// preserva la propiedad "1 op = 1 cambio efectivo" del log.
///
/// Preserva el orden del input para que el log entry sea estable.
pub fn compute_clear_fields(current: &Value, to_clear: &[String]) -> Vec<String> {
to_clear
.iter()
.filter(|f| match current.get(f.as_str()) {
None | Some(Value::Null) => false,
Some(_) => true,
})
.cloned()
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
fn map(items: &[(&str, Value)]) -> serde_json::Map<String, Value> {
items.iter().map(|(k, v)| (k.to_string(), v.clone())).collect()
}
#[test]
fn delta_empty_when_all_fields_match() {
let current = json!({"name": "Acme", "saldo": 100_i64, "currency": "USD"});
let proposed = map(&[
("name", json!("Acme")),
("saldo", json!(100_i64)),
("currency", json!("USD")),
]);
assert!(compute_field_delta(&current, &proposed).is_empty());
}
#[test]
fn delta_includes_only_changed_field() {
let current = json!({"name": "Acme", "saldo": 100_i64});
let proposed = map(&[("name", json!("Acme")), ("saldo", json!(200_i64))]);
let d = compute_field_delta(&current, &proposed);
assert_eq!(d.len(), 1);
assert_eq!(d.get("saldo"), Some(&json!(200_i64)));
}
#[test]
fn delta_treats_missing_record_as_all_new() {
let current = Value::Null;
let proposed = map(&[("name", json!("Acme")), ("saldo", json!(0_i64))]);
assert_eq!(compute_field_delta(&current, &proposed).len(), 2);
}
#[test]
fn delta_distinguishes_int_from_string_repr() {
let current = json!({"qty": 100_i64});
let proposed = map(&[("qty", json!(100_i64))]);
assert!(compute_field_delta(&current, &proposed).is_empty());
let current_str = json!({"qty": "100"});
let proposed_int = map(&[("qty", json!(100_i64))]);
assert_eq!(compute_field_delta(&current_str, &proposed_int).len(), 1);
}
#[test]
fn delta_skips_fields_absent_from_proposed() {
let current = json!({"name": "Acme", "saldo": 100_i64, "extra": "x"});
let proposed = map(&[("name", json!("Acme")), ("saldo", json!(150_i64))]);
let d = compute_field_delta(&current, &proposed);
assert_eq!(d.len(), 1);
assert!(!d.contains_key("extra"));
}
#[test]
fn clear_fields_skips_absent_and_null() {
let current = json!({"name": "Acme", "notes": "lorem", "tag": null});
let to_clear = vec![
"name".into(),
"notes".into(),
"tag".into(),
"missing".into(),
];
assert_eq!(
compute_clear_fields(&current, &to_clear),
vec!["name".to_string(), "notes".to_string()]
);
}
#[test]
fn clear_fields_preserves_input_order() {
let current = json!({"a": 1, "b": 2, "c": 3});
let to_clear = vec!["c".into(), "a".into(), "b".into()];
assert_eq!(
compute_clear_fields(&current, &to_clear),
vec!["c", "a", "b"]
);
}
#[test]
fn clear_fields_empty_when_current_is_null() {
let current = Value::Null;
let to_clear = vec!["name".into()];
assert!(compute_clear_fields(&current, &to_clear).is_empty());
}
}
@@ -0,0 +1,185 @@
//! Helpers de presentación humana para records y values.
//!
//! Sin GPUI: devuelven `String`s. El widget renderer los wrap-ea
//! en `div().child(...)` o equivalente.
use serde_json::Value;
use uuid::Uuid;
/// Etiqueta humana para representar un record en el selector de
/// EntityRef. Heurística: prefiere campos comunes en este orden:
/// `name`, `label`, `title`, `sku`, `sku_id`. Fallback al UUID corto.
pub fn human_label_for_record(value: &Value, id: &Uuid) -> String {
for key in ["name", "label", "title", "sku", "sku_id"] {
if let Some(v) = value.get(key).and_then(Value::as_str) {
if !v.is_empty() {
return format!("{} ({})", v, short_uuid(id));
}
}
}
short_uuid(id)
}
/// Render legible de un `Value` arbitrario para mostrar en una celda
/// de lista. Strings van pelados; bools como ✓/✗; el resto via
/// `Display`.
pub 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(),
}
}
/// Conversión inversa a `parse_field_value`: del JSON al texto raw
/// que un input puede tomar y volver a parsearse igual al submit.
/// Usado para pre-llenar inputs en modo edit.
pub fn value_to_input_text(v: &Value) -> String {
match v {
Value::Null => String::new(),
Value::String(s) => s.clone(),
Value::Bool(b) => if *b { "true" } else { "false" }.to_string(),
Value::Number(n) => n.to_string(),
other => other.to_string(),
}
}
/// Primeros 8 chars del UUID en forma canónica. Útil para logs y UI
/// donde el UUID full es ruido visual.
pub fn short_uuid(id: &Uuid) -> String {
id.to_string().chars().take(8).collect()
}
/// Hex string de los primeros 4 bytes de un hash SHA-256 (8
/// caracteres). Útil para mostrar bundle/schema hashes en UI sin
/// quemar pantalla con los 64 chars completos.
pub fn short_hash(h: &[u8; 32]) -> String {
use std::fmt::Write;
let mut s = String::with_capacity(8);
for b in h.iter().take(4) {
let _ = write!(s, "{:02x}", b);
}
s
}
/// Renderea un `serde_json::Value` en una sola línea, truncado a
/// `max` caracteres con `...` al final si excede. Para preview en
/// timelines/cards/listas — NO para edición.
///
/// `max` es un upper-bound aproximado: el resultado nunca excede
/// `max` chars, pero puede ser más corto si el value es chico.
pub fn preview_value(v: &Value, max: usize) -> String {
let s = v.to_string();
if s.chars().count() <= max {
s
} else if max < 3 {
s.chars().take(max).collect()
} else {
let truncated: String = s.chars().take(max - 3).collect();
format!("{truncated}...")
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn human_label_prefers_name_over_id() {
let id = Uuid::new_v4();
let v = json!({"name": "Acme S.A.", "email": "x@y.z"});
let label = human_label_for_record(&v, &id);
assert!(label.starts_with("Acme S.A."));
assert!(label.contains(&short_uuid(&id)));
}
#[test]
fn human_label_falls_back_through_label_title_sku() {
let id = Uuid::new_v4();
let only_label = json!({"label": "X"});
assert!(human_label_for_record(&only_label, &id).starts_with("X "));
let only_title = json!({"title": "Y"});
assert!(human_label_for_record(&only_title, &id).starts_with("Y "));
let only_sku = json!({"sku": "Z"});
assert!(human_label_for_record(&only_sku, &id).starts_with("Z "));
let only_sku_id = json!({"sku_id": "W"});
assert!(human_label_for_record(&only_sku_id, &id).starts_with("W "));
}
#[test]
fn human_label_falls_back_to_short_uuid_when_no_keys_match() {
let id = Uuid::new_v4();
let v = json!({"random": "field"});
assert_eq!(human_label_for_record(&v, &id), short_uuid(&id));
}
#[test]
fn render_value_handles_basic_kinds() {
assert_eq!(render_value(None), "");
assert_eq!(render_value(Some(&Value::Null)), "");
assert_eq!(render_value(Some(&json!("hola"))), "hola");
assert_eq!(render_value(Some(&json!(true))), "");
assert_eq!(render_value(Some(&json!(false))), "");
assert_eq!(render_value(Some(&json!(42))), "42");
}
#[test]
fn value_to_input_text_round_trip_with_strings_and_numbers() {
assert_eq!(value_to_input_text(&Value::Null), "");
assert_eq!(value_to_input_text(&json!("x")), "x");
assert_eq!(value_to_input_text(&json!(true)), "true");
assert_eq!(value_to_input_text(&json!(false)), "false");
assert_eq!(value_to_input_text(&json!(42)), "42");
}
#[test]
fn short_hash_takes_first_4_bytes_hex() {
let mut h = [0u8; 32];
h[0] = 0xaa;
h[1] = 0xbb;
h[2] = 0xcc;
h[3] = 0xdd;
assert_eq!(short_hash(&h), "aabbccdd");
}
#[test]
fn short_hash_zeros() {
let h = [0u8; 32];
assert_eq!(short_hash(&h), "00000000");
}
#[test]
fn preview_value_keeps_short_strings_intact() {
let v = json!({"a": 1});
assert_eq!(preview_value(&v, 30), "{\"a\":1}");
}
#[test]
fn preview_value_truncates_long_strings_with_ellipsis() {
let v = json!({"a": "x".repeat(200)});
let p = preview_value(&v, 30);
assert!(p.chars().count() <= 30);
assert!(p.ends_with("..."));
}
#[test]
fn preview_value_handles_max_smaller_than_ellipsis() {
// Edge case: max < 3 (no espacio para "..."). Devuelve
// los primeros `max` chars sin sufijo, sin panic.
let v = json!("xxxxxxxxxxxxxxxx");
let p = preview_value(&v, 2);
assert!(p.chars().count() <= 2);
}
#[test]
fn short_uuid_returns_first_8_chars() {
let id = Uuid::parse_str("01ARZ3ND-EKTS-V4RR-FFQ6-9G5FAV000000").ok();
// Si el parse falla, usamos uno fresco — el invariant es la
// longitud, no el contenido.
let id = id.unwrap_or_else(Uuid::new_v4);
assert_eq!(short_uuid(&id).len(), 8);
}
}
@@ -0,0 +1,39 @@
//! `nahual-meta-runtime` — helpers puros para runtimes metainterfaz.
//!
//! Consume [`nahual_meta_schema`] (los tipos `Module`/`View`/`FieldSpec`/
//! `FieldKind`/`Action`/etc.) y aporta funciones puras que cualquier
//! widget renderer o backend ejecutor necesita:
//!
//! - **Parse**: convertir el texto de un input a `serde_json::Value`
//! tipado según el `FieldKind` del spec.
//! - **Delta**: calcular qué cambió entre el estado actual y la
//! propuesta del form (Set + Clear).
//! - **Validation**: verificar que cada EntityRef apunte a un record
//! que existe (toma cierre `load`, no trait).
//! - **Format**: presentación humana de records (label heurístico,
//! render de values, UUID corto, round-trip a input text).
//!
//! Sin GPUI, sin acoplamiento a un backend específico. Cualquier
//! implementación de store/log puede consumirlos.
//!
//! El widget render (form/list/modal) vive en otro crate nahual
//! que esto consume; el runtime concreto (`nakui-ui`) implementa la
//! conexión a su event-log/executor y compone ambos.
#![forbid(unsafe_code)]
pub mod backend;
pub mod delta;
pub mod format;
pub mod parse;
pub mod refs;
pub mod testing;
pub use backend::{MetaBackend, WriteOutcome};
pub use delta::{compute_clear_fields, compute_field_delta};
pub use format::{
human_label_for_record, preview_value, render_value, short_hash, short_uuid,
value_to_input_text,
};
pub use parse::{infer_param_value, parse_field_value, resolve_param_value};
pub use refs::validate_entity_refs;
@@ -0,0 +1,231 @@
//! Parseo de inputs del form a `serde_json::Value` tipado.
use serde_json::{json, Value};
use uuid::Uuid;
use nahual_meta_schema::{FieldKind, FieldSpec};
/// Convierte el texto raw de un input al `Value` tipado según el
/// `kind` del spec.
///
/// - `Text` / `Multiline` / `Date` → string passthrough.
/// - `EntityRef` → string del UUID **trimmed**, validado como UUID
/// parseable. Falla con mensaje claro si no parsea.
/// - `Boolean` → variantes comunes (`true/yes/1/on/y` y `false/no/0/off/n`).
/// - `Number` → i64 si parsea, sino f64.
pub fn parse_field_value(kind: FieldKind, raw: &str) -> Result<Value, String> {
match kind {
FieldKind::Text | FieldKind::Multiline | FieldKind::Date => Ok(json!(raw)),
// EntityRef se almacena como string del UUID seleccionado.
// El selector clickable garantiza UUIDs válidos en happy
// path; este check protege paste manual o garbage tipeado.
FieldKind::EntityRef => {
let trimmed = raw.trim();
Uuid::parse_str(trimmed).map_err(|_| {
format!("'{raw}' no es UUID válido (usá el selector de records)")
})?;
Ok(json!(trimmed))
}
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"))
}
}
}
}
/// Resuelve un param de morphism a su `Value` según el `FieldSpec`
/// del form. **Strict path**: si hay spec, valida `required` y parsea
/// con el `kind` declarado (ej. Boolean rebota con "abc" antes de
/// llegar al morphism). **Fallback path**: si no hay spec (param
/// declarado en `Action::Morphism.params` que no aparece en
/// `form.fields`), usa la heurística [`infer_param_value`] para no
/// quedar atado a un schema mal-formado.
///
/// Errores tienen el label legible del spec, así el toast de la UI
/// es interpretable.
pub fn resolve_param_value(
field_name: &str,
raw: &str,
spec: Option<&FieldSpec>,
) -> Result<Value, String> {
let Some(s) = spec else {
return Ok(infer_param_value(raw));
};
let label = if s.label.is_empty() { field_name } else { &s.label };
if s.required && raw.trim().is_empty() {
return Err(format!("param '{label}' es obligatorio y está vacío"));
}
if raw.is_empty() && !s.required {
return Ok(Value::Null);
}
parse_field_value(s.kind, raw).map_err(|e| format!("param '{label}': {e}"))
}
/// Inferencia de tipo para values pasados como `params` a un
/// morphism. Usada como fallback en [`resolve_param_value`] cuando el
/// param declarado en `Action::Morphism.params` no aparece en los
/// `form.fields` (módulo mal-formado).
///
/// Heurística simple: int → i64, float → f64, "true"/"false" → bool,
/// resto → string.
pub fn infer_param_value(raw: &str) -> Value {
if raw.is_empty() {
return Value::Null;
}
if let Ok(i) = raw.parse::<i64>() {
return json!(i);
}
if let Ok(f) = raw.parse::<f64>() {
return json!(f);
}
match raw {
"true" => return json!(true),
"false" => return json!(false),
_ => {}
}
json!(raw)
}
#[cfg(test)]
mod tests {
use super::*;
use nahual_meta_schema::FieldSpec;
fn spec(name: &str, kind: FieldKind, required: bool) -> FieldSpec {
FieldSpec {
name: name.into(),
label: name.into(),
kind,
default: None,
required,
help: None,
ref_entity: None,
}
}
#[test]
fn infer_handles_basic_types() {
assert_eq!(infer_param_value(""), Value::Null);
assert_eq!(infer_param_value("42"), json!(42));
assert_eq!(infer_param_value("3.14"), json!(3.14));
assert_eq!(infer_param_value("true"), json!(true));
assert_eq!(infer_param_value("false"), json!(false));
assert_eq!(infer_param_value("hola"), json!("hola"));
}
#[test]
fn parse_text_passthrough() {
let v = parse_field_value(FieldKind::Text, "hola").unwrap();
assert_eq!(v, json!("hola"));
}
#[test]
fn parse_number_i64_or_f64() {
assert_eq!(parse_field_value(FieldKind::Number, "42").unwrap(), json!(42));
assert_eq!(
parse_field_value(FieldKind::Number, "3.14").unwrap(),
json!(3.14)
);
assert!(parse_field_value(FieldKind::Number, "abc").is_err());
}
#[test]
fn parse_boolean_recognizes_variants() {
for s in ["true", "yes", "1", "on", "y"] {
assert_eq!(parse_field_value(FieldKind::Boolean, s).unwrap(), json!(true));
}
for s in ["false", "no", "0", "off", "n", ""] {
assert_eq!(
parse_field_value(FieldKind::Boolean, s).unwrap(),
json!(false)
);
}
assert!(parse_field_value(FieldKind::Boolean, "abc").is_err());
}
#[test]
fn parse_entity_ref_accepts_valid_uuid() {
let id = Uuid::new_v4();
let v = parse_field_value(FieldKind::EntityRef, &id.to_string()).unwrap();
assert_eq!(v, json!(id.to_string()));
}
#[test]
fn parse_entity_ref_trims_whitespace() {
let id = Uuid::new_v4();
let padded = format!(" {id}\n");
let v = parse_field_value(FieldKind::EntityRef, &padded).unwrap();
assert_eq!(v, json!(id.to_string()));
}
#[test]
fn parse_entity_ref_rejects_non_uuid() {
let err = parse_field_value(FieldKind::EntityRef, "abc-123").unwrap_err();
assert!(err.contains("'abc-123'"));
assert!(err.contains("UUID") || err.contains("uuid"));
}
#[test]
fn parse_entity_ref_rejects_empty_string() {
let err = parse_field_value(FieldKind::EntityRef, "").unwrap_err();
assert!(err.contains("UUID"));
}
#[test]
fn resolve_param_strict_number_parses_i64() {
let s = spec("qty", FieldKind::Number, true);
let v = resolve_param_value("qty", "42", Some(&s)).unwrap();
assert_eq!(v, json!(42));
}
#[test]
fn resolve_param_strict_boolean_rejects_non_boolean() {
let s = spec("active", FieldKind::Boolean, true);
let err = resolve_param_value("active", "abc", Some(&s)).unwrap_err();
assert!(err.contains("active"));
}
#[test]
fn resolve_param_required_empty_rejected() {
let s = spec("name", FieldKind::Text, true);
let err = resolve_param_value("name", " ", Some(&s)).unwrap_err();
assert!(err.contains("obligatorio"));
}
#[test]
fn resolve_param_optional_empty_returns_null() {
let s = spec("notes", FieldKind::Text, false);
let v = resolve_param_value("notes", "", Some(&s)).unwrap();
assert_eq!(v, Value::Null);
}
#[test]
fn resolve_param_no_spec_falls_back_to_infer() {
let v = resolve_param_value("foo", "42", None).unwrap();
assert_eq!(v, json!(42));
let v = resolve_param_value("foo", "true", None).unwrap();
assert_eq!(v, json!(true));
let v = resolve_param_value("foo", "x", None).unwrap();
assert_eq!(v, json!("x"));
}
#[test]
fn resolve_param_strict_entity_ref_propagates_error() {
let s = spec("stock_ref", FieldKind::EntityRef, true);
let err = resolve_param_value("stock_ref", "not-a-uuid", Some(&s)).unwrap_err();
assert!(err.contains("stock_ref"));
assert!(err.contains("UUID"));
}
}
@@ -0,0 +1,108 @@
//! Validación cross-field de EntityRefs contra el store actual.
//!
//! Decoupling: en vez de un `trait Store` que ate este crate a un
//! backend específico, tomamos un cierre `load: Fn(&str, Uuid) ->
//! Option<Value>`. El caller (nakui-ui o cualquier otro runtime)
//! puede pasarlo trivialmente sobre cualquier store (MemoryStore,
//! SurrealStore, mock, ...).
use serde_json::Value;
use uuid::Uuid;
use crate::format::short_uuid;
/// Valida que cada UUID en `refs` apunte a un record que realmente
/// existe en el store bajo la entity esperada. Devuelve el primer
/// error encontrado (fail-fast).
///
/// `refs` es una lista de `(label, target_entity, uuid)`. El label
/// va al error message, así que conviene que sea legible (ej:
/// `FieldSpec.label` en lugar de `FieldSpec.name`).
///
/// `load` es el cierre que el caller usa para mirar el store —
/// típicamente `|e, id| store.load(e, id)`.
pub fn validate_entity_refs<F>(load: F, refs: &[(String, String, Uuid)]) -> Result<(), String>
where
F: Fn(&str, Uuid) -> Option<Value>,
{
for (label, target, id) in refs {
if load(target, *id).is_none() {
return Err(format!(
"campo '{label}': record {} de '{target}' no existe en el store",
short_uuid(id)
));
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
use std::collections::HashMap;
/// "Mock store" minimalista para tests: HashMap por (entity, uuid).
fn mk_load(records: HashMap<(String, Uuid), Value>) -> impl Fn(&str, Uuid) -> Option<Value> {
move |e, id| records.get(&(e.to_string(), id)).cloned()
}
#[test]
fn passes_when_all_records_exist() {
let stock = Uuid::new_v4();
let caja = Uuid::new_v4();
let mut records = HashMap::new();
records.insert(("Stock".into(), stock), json!({"sku_id": "abc"}));
records.insert(("Caja".into(), caja), json!({"name": "Principal"}));
let load = mk_load(records);
let refs = vec![
("Stock".into(), "Stock".into(), stock),
("Caja".into(), "Caja".into(), caja),
];
assert!(validate_entity_refs(load, &refs).is_ok());
}
#[test]
fn fails_on_first_missing() {
let stock = Uuid::new_v4();
let mut records = HashMap::new();
records.insert(("Stock".into(), stock), json!({"sku_id": "abc"}));
let load = mk_load(records);
let missing_caja = Uuid::new_v4();
let refs = vec![
("Stock".into(), "Stock".into(), stock),
("Caja".into(), "Caja".into(), missing_caja),
];
let err = validate_entity_refs(load, &refs).unwrap_err();
assert!(err.contains("Caja"));
assert!(err.contains(&short_uuid(&missing_caja)));
}
#[test]
fn uses_label_not_entity_in_msg() {
let load = |_: &str, _: Uuid| -> Option<Value> { None };
let id = Uuid::new_v4();
let refs = vec![("Stock origen".into(), "Stock".into(), id)];
let err = validate_entity_refs(load, &refs).unwrap_err();
assert!(err.contains("Stock origen"));
}
#[test]
fn empty_list_is_ok() {
let load = |_: &str, _: Uuid| -> Option<Value> { None };
assert!(validate_entity_refs(load, &[]).is_ok());
}
#[test]
fn distinguishes_target_from_other_entities() {
let id = Uuid::new_v4();
let mut records = HashMap::new();
// Mismo UUID bajo Customer pero NO bajo Stock.
records.insert(("Customer".into(), id), json!({"name": "Acme"}));
let load = mk_load(records);
let refs = vec![("Stock".into(), "Stock".into(), id)];
assert!(validate_entity_refs(load, &refs).is_err());
}
}
@@ -0,0 +1,339 @@
//! Utilidades de testing para code que consume [`MetaBackend`].
//!
//! Provee [`MockBackend`]: implementación in-memory minimalista
//! del trait, sin acoplamiento a stores reales (event log,
//! SurrealDB, etc.). Útil para:
//!
//! - Tests del widget [`nahual_widget_meta_form::MetaApp`] que
//! necesitan un backend funcional sin levantar nakui-core.
//! - Tests de cualquier consumer que tome `B: MetaBackend` y quiera
//! asserts sobre lecturas/escrituras sin tocar disco.
//! - Fixtures pre-pobladas para demos/screenshots/CI.
//!
//! Está bajo `pub mod testing` (no `#[cfg(test)]`) deliberadamente
//! para que crates downstream puedan importarlo en sus dev/integ
//! tests. No tiene overhead en producción si no se usa.
use std::collections::{BTreeMap, HashMap};
use serde_json::Value;
use uuid::Uuid;
use crate::backend::{MetaBackend, WriteOutcome};
/// Backend in-memory para tests. Implementa el contrato completo
/// del [`MetaBackend`] con semantica simple:
///
/// - `seed`: genera Uuid v4, inserta record. `changed = 1`.
/// - `update`: aplica `set` (overrides) y `clear` (key removal).
/// Si ambos vacíos → `changed = 0`. Falla si record no existe.
/// - `delete`: remueve record. Falla si no existe.
/// - `morphism`: por default rebota con error
/// `"MockBackend no soporta morphism '<name>'"`. Si querés
/// simular morphisms, registrá callbacks via
/// [`MockBackend::with_morphism`].
/// - `list_records`: orden lexicográfico por id (estable).
/// - Sin `post_status`: el mock no tiene tick/compact.
///
/// Métodos de inspección públicos ([`total_records`],
/// [`records_for`], etc.) facilitan asserts en tests sin necesidad
/// de re-leer el state via las APIs del trait.
pub struct MockBackend {
records: HashMap<(String, Uuid), Value>,
morphisms: HashMap<String, MorphismHandler>,
}
type MorphismHandler =
Box<dyn Fn(&BTreeMap<String, Uuid>, &Value) -> Result<usize, String> + Send + Sync>;
impl Default for MockBackend {
fn default() -> Self {
Self::new()
}
}
impl MockBackend {
/// Backend vacío.
pub fn new() -> Self {
Self {
records: HashMap::new(),
morphisms: HashMap::new(),
}
}
/// Pre-popula el backend con records `(entity, uuid, data)`.
/// Útil para fixtures: asserts sobre lecturas sin tener que
/// armar seeds via `seed()`.
pub fn with_records<I>(records: I) -> Self
where
I: IntoIterator<Item = (String, Uuid, Value)>,
{
let mut b = Self::new();
for (entity, id, data) in records {
b.records.insert((entity, id), data);
}
b
}
/// Registra un handler para un morphism de nombre `name`.
/// El handler recibe inputs + params y devuelve `changed` o
/// `Err` para simular fallo del morphism. Sobrescribe cualquier
/// handler previo del mismo nombre.
pub fn with_morphism<F>(mut self, name: impl Into<String>, handler: F) -> Self
where
F: Fn(&BTreeMap<String, Uuid>, &Value) -> Result<usize, String> + Send + Sync + 'static,
{
self.morphisms.insert(name.into(), Box::new(handler));
self
}
/// Cantidad total de records en el backend (todas las entities).
pub fn total_records(&self) -> usize {
self.records.len()
}
/// Records de una entity como `Vec<(Uuid, &Value)>` sin clones
/// (más liviano que `list_records` cuando el caller sólo quiere
/// inspeccionar).
pub fn records_for<'a>(&'a self, entity: &str) -> Vec<(Uuid, &'a Value)> {
self.records
.iter()
.filter(|((e, _), _)| e == entity)
.map(|((_, id), v)| (*id, v))
.collect()
}
}
impl MetaBackend for MockBackend {
fn list_records(&self, entity: &str) -> Vec<(Uuid, Value)> {
let mut out: Vec<(Uuid, Value)> = self
.records
.iter()
.filter(|((e, _), _)| e == entity)
.map(|((_, id), v)| (*id, v.clone()))
.collect();
out.sort_by(|a, b| a.0.as_bytes().cmp(b.0.as_bytes()));
out
}
fn load_record(&self, entity: &str, id: Uuid) -> Option<Value> {
self.records.get(&(entity.to_string(), id)).cloned()
}
fn seed(
&mut self,
entity: &str,
data: serde_json::Map<String, Value>,
) -> Result<WriteOutcome, String> {
let id = Uuid::new_v4();
self.records
.insert((entity.to_string(), id), Value::Object(data));
Ok(WriteOutcome {
id: Some(id),
changed: 1,
post_status: None,
})
}
fn update(
&mut self,
entity: &str,
id: Uuid,
set: serde_json::Map<String, Value>,
clear: Vec<String>,
) -> Result<WriteOutcome, String> {
if set.is_empty() && clear.is_empty() {
return Ok(WriteOutcome::no_change(id));
}
let rec = self
.records
.get_mut(&(entity.to_string(), id))
.ok_or_else(|| format!("not found: {entity}/{id}"))?;
let map = rec
.as_object_mut()
.ok_or_else(|| format!("not an object: {entity}/{id}"))?;
let changed = set.len() + clear.len();
for (k, v) in set {
map.insert(k, v);
}
for k in clear {
map.remove(&k);
}
Ok(WriteOutcome {
id: Some(id),
changed,
post_status: None,
})
}
fn delete(&mut self, entity: &str, id: Uuid) -> Result<WriteOutcome, String> {
self.records
.remove(&(entity.to_string(), id))
.ok_or_else(|| format!("not found: {entity}/{id}"))?;
Ok(WriteOutcome {
id: Some(id),
changed: 1,
post_status: None,
})
}
fn morphism(
&mut self,
_module_id: &str,
name: &str,
inputs: BTreeMap<String, Uuid>,
params: Value,
) -> Result<WriteOutcome, String> {
match self.morphisms.get(name) {
Some(handler) => {
let changed = handler(&inputs, &params)?;
Ok(WriteOutcome {
id: None,
changed,
post_status: None,
})
}
None => Err(format!("MockBackend no soporta morphism '{name}'")),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
fn map_of(items: &[(&str, Value)]) -> serde_json::Map<String, Value> {
items.iter().map(|(k, v)| (k.to_string(), v.clone())).collect()
}
#[test]
fn with_records_populates_state() {
let id = Uuid::new_v4();
let b = MockBackend::with_records([(
"Customer".into(),
id,
json!({"name": "Acme"}),
)]);
assert_eq!(b.total_records(), 1);
assert_eq!(
b.load_record("Customer", id),
Some(json!({"name": "Acme"}))
);
}
#[test]
fn seed_then_load_round_trip_via_trait() {
let mut b = MockBackend::new();
let out = b
.seed("X", map_of(&[("k", json!(1))]))
.unwrap();
let id = out.id.unwrap();
assert_eq!(out.changed, 1);
assert_eq!(b.load_record("X", id), Some(json!({"k": 1})));
}
#[test]
fn update_no_op_returns_no_change() {
let id = Uuid::new_v4();
let mut b = MockBackend::with_records([(
"X".into(),
id,
json!({"k": 1}),
)]);
let out = b
.update("X", id, serde_json::Map::new(), vec![])
.unwrap();
assert_eq!(out, WriteOutcome::no_change(id));
}
#[test]
fn update_set_and_clear_aplica_ambos() {
let id = Uuid::new_v4();
let mut b = MockBackend::with_records([(
"X".into(),
id,
json!({"a": 1, "b": 2}),
)]);
let out = b
.update("X", id, map_of(&[("a", json!(10))]), vec!["b".into()])
.unwrap();
assert_eq!(out.changed, 2);
let rec = b.load_record("X", id).unwrap();
assert_eq!(rec.get("a"), Some(&json!(10)));
assert!(rec.get("b").is_none());
}
#[test]
fn delete_then_load_returns_none() {
let id = Uuid::new_v4();
let mut b = MockBackend::with_records([(
"X".into(),
id,
json!({"k": 1}),
)]);
b.delete("X", id).unwrap();
assert!(b.load_record("X", id).is_none());
}
#[test]
fn morphism_without_handler_errors_clearly() {
let mut b = MockBackend::new();
let err = b
.morphism("mod", "foo", BTreeMap::new(), json!({}))
.unwrap_err();
assert!(err.contains("foo"));
}
#[test]
fn with_morphism_lets_caller_simulate_logic() {
let mut b = MockBackend::new().with_morphism(
"double_qty",
|inputs, params| {
assert!(inputs.is_empty());
let qty = params.get("qty").and_then(|v| v.as_i64()).unwrap_or(0);
if qty <= 0 {
return Err("qty must be positive".into());
}
Ok(qty as usize)
},
);
let out = b
.morphism("mod", "double_qty", BTreeMap::new(), json!({"qty": 7}))
.unwrap();
assert_eq!(out.changed, 7);
assert!(out.id.is_none(), "morphism no devuelve id por convención");
let err = b
.morphism("mod", "double_qty", BTreeMap::new(), json!({"qty": 0}))
.unwrap_err();
assert!(err.contains("positive"));
}
#[test]
fn list_records_orders_lexicographically() {
let id_a = Uuid::parse_str("00000000-0000-0000-0000-000000000001").unwrap();
let id_b = Uuid::parse_str("ffffffff-0000-0000-0000-000000000000").unwrap();
let b = MockBackend::with_records([
("X".into(), id_b, json!({"n": 2})),
("X".into(), id_a, json!({"n": 1})),
]);
let rows = b.list_records("X");
assert_eq!(rows.len(), 2);
assert_eq!(rows[0].0, id_a, "menor uuid primero (orden lex)");
}
#[test]
fn records_for_returns_borrowed_view() {
let id = Uuid::new_v4();
let b = MockBackend::with_records([(
"X".into(),
id,
json!({"k": 1}),
)]);
let view = b.records_for("X");
assert_eq!(view.len(), 1);
assert_eq!(view[0].0, id);
assert_eq!(view[0].1.get("k"), Some(&json!(1)));
}
}
@@ -0,0 +1,14 @@
[package]
name = "nahual-meta-schema"
version.workspace = true
edition.workspace = true
license.workspace = true
description = "Yahweh — meta-schema: descriptores declarativos de UI (entities, menús, listas, formularios, acciones) consumidos por widgets metainterfaz reusables. Independiente del backend: cualquier app que monte una UI dirigida por datos puede usarlo."
[dependencies]
serde = { workspace = true }
serde_json = { workspace = true }
thiserror = { workspace = true }
[dev-dependencies]
tempfile = { workspace = true }
@@ -0,0 +1,642 @@
//! Schema declarativo de la metainterfaz (nahual meta-schema).
//!
//! Cada **módulo** declara aquí qué menús, vistas, listas y
//! formularios expone, sin escribir código GPUI ni Rust. Cualquier
//! runtime de UI dirigida por datos (Nakui hoy, otros mañana) lo
//! carga y monta la UI correspondiente.
//!
//! ## Filosofía
//!
//! - **UI como datos**: agregar un módulo = escribir un JSON o un
//! `.ncl`. Ningún recompile, ningún acoplamiento con el binario
//! del runtime.
//! - **Backend-agnostic**: este crate sólo describe la *forma* de
//! la UI. La conexión a un store/log/executor concretos vive en
//! el runtime que lo consume (ej: el meta-runtime de Nakui que
//! wirea esto a `nakui_core` + KCL post-checks).
//! - **Schema primero, semántica después**: validación semántica
//! (referencias rotas a entities, campos faltantes, etc.) vive
//! en el runtime que lo carga, no acá.
//!
//! ## Anatomía de un módulo
//!
//! ```json
//! {
//! "id": "customers",
//! "label": "Clientes",
//! "entities": [
//! { "name": "customer", "fields": [ ... ] }
//! ],
//! "menu": [
//! { "label": "Listar", "view": "list" },
//! { "label": "Nuevo", "view": "form" }
//! ],
//! "views": {
//! "list": { "kind": "list", "entity": "customer", "columns": [...] },
//! "form": { "kind": "form", "entity": "customer", "fields": [...] }
//! }
//! }
//! ```
#![forbid(unsafe_code)]
#![warn(rust_2018_idioms)]
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
use thiserror::Error;
/// Manifiesto de un módulo declarativo de UI.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Module {
/// Identificador estable. Único dentro del directorio cargado.
pub id: String,
/// Nombre legible para mostrar en el sidebar.
pub label: String,
/// Descripción corta opcional (tooltip / subtítulo).
#[serde(default)]
pub description: Option<String>,
/// Entities que el módulo introduce o consume. El runtime las
/// usa para validar columns/fields y para inicializar el store
/// cuando son nuevas.
#[serde(default)]
pub entities: Vec<EntitySpec>,
/// Path opaco al backend que va a manejar este módulo. Lo
/// interpreta el runtime concreto (no este schema).
///
/// Convención actual de Nakui: directorio con `nsmc.json` +
/// schemas KCL + scripts Rhai. Cuando está set, el runtime
/// Nakui carga un `Executor` para ese path y permite que las
/// acciones `Morphism { name }` despachen al pipeline real
/// (compute → log → apply). Otro backend puede ignorar este
/// campo o darle un significado distinto.
///
/// Path resuelto relativo al directorio del `module.json`
/// o absoluto.
///
/// Si es `None`, los backends que requieren manifest deberían
/// degradar (toast informativo, deshabilitar morphisms, etc.);
/// los `SeedEntity` siguen funcionando — son altas
/// administrativas que no necesitan validación de manifest.
///
/// Nombre conservado por compat con módulos ya escritos.
/// Renombrar a `backend_module_dir` o similar si emerge un
/// segundo backend que también lo use.
#[serde(default, alias = "backend_module_dir")]
pub nakui_module_dir: Option<String>,
/// Items del menú. Cada uno apunta a una key de `views`. Orden
/// importa (es el orden en que se presentan en el sidebar).
pub menu: Vec<MenuItem>,
/// Vistas indexadas por key. Las keys son referenciadas por
/// `MenuItem.view` y por `Action::OpenView.view`.
pub views: BTreeMap<String, View>,
}
/// Item del menú lateral.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MenuItem {
pub label: String,
/// Key de la vista a abrir. Debe existir en `Module.views`.
pub view: String,
/// Icono opcional (texto unicode o emoji; el runtime decide
/// renderización).
#[serde(default)]
pub icon: Option<String>,
}
/// Una vista renderizada en el área principal cuando su menú es seleccionado.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum View {
/// Tabla de instancias de una entity, columnas + acciones por fila.
List(ListView),
/// Formulario de creación / edición.
Form(FormView),
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ListView {
pub title: String,
/// Entity (del nakui store) cuyas instancias se listan.
pub entity: String,
/// Columnas a mostrar. Orden importa.
pub columns: Vec<Column>,
/// Acciones disponibles a nivel de la lista (ej. "Nuevo" → form).
/// Renderizadas como botones en el header.
#[serde(default)]
pub actions: Vec<Action>,
/// Cuando está set, se muestra una caja de búsqueda que filtra
/// las filas por substring contra los valores de estas columnas.
#[serde(default)]
pub search_in: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Column {
/// Path del campo dentro del record. Para tipos planos: nombre.
/// Para nested: puntos (`address.city`). El runtime navega.
pub field: String,
/// Texto del header.
pub label: String,
/// Ancho relativo (peso flex). Default 1.
#[serde(default = "default_weight")]
pub weight: f32,
}
fn default_weight() -> f32 {
1.0
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FormView {
pub title: String,
/// Entity destino del seed/morphism al submit.
pub entity: String,
pub fields: Vec<FieldSpec>,
/// Acción al submit. Típicamente `Action::SeedEntity` para alta
/// directa o `Action::Morphism` cuando hay validación/cálculo.
pub on_submit: Action,
}
/// Especificación de un campo de formulario o columna implícita.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FieldSpec {
/// Nombre del campo en el record (clave del JSON).
pub name: String,
/// Etiqueta legible.
pub label: String,
/// Tipo del valor — define el widget de input + parseo.
pub kind: FieldKind,
/// Valor por defecto al abrir el form (string raw; el parseo
/// según `kind` lo hace el runtime).
#[serde(default)]
pub default: Option<String>,
/// Si `true`, el form rechaza submit con campo vacío.
#[serde(default)]
pub required: bool,
/// Texto de ayuda mostrado bajo el input.
#[serde(default)]
pub help: Option<String>,
/// Si `kind == EntityRef`, indica qué entity referencia. Sin
/// esto, el runtime no sabe qué records ofrecer en el selector
/// y la validación `Module::validate` rechaza el manifest.
/// Para los demás kinds, este campo se ignora.
#[serde(default)]
pub ref_entity: Option<String>,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum FieldKind {
/// Texto libre.
Text,
/// Texto multilínea.
Multiline,
/// Número (i64 o f64; runtime intenta parsear como i64 primero).
Number,
/// Booleano (renderizado como checkbox).
Boolean,
/// Fecha (formato ISO YYYY-MM-DD; almacenada como string).
Date,
/// Referencia a otro record. El runtime renderiza un selector
/// clickable de records existentes de la entity declarada en
/// `FieldSpec.ref_entity`; el value almacenado es el UUID del
/// seleccionado, parseable como cualquier text/UUID al submit.
EntityRef,
}
/// Acciones disparables por menús, botones o submit de formularios.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum Action {
/// Cambia la vista activa a otra del mismo módulo.
OpenView {
view: String,
/// Etiqueta del botón / item; default = nombre humano de la vista.
#[serde(default)]
label: Option<String>,
},
/// Crea un seed directo en la entity con los valores del form.
/// Equivalente a `nakui_core::event_log::seed_and_log`.
/// Sin pipeline de morphism — alta administrativa.
SeedEntity {
entity: String,
/// Tras el submit exitoso, opcionalmente abrir esta vista
/// (por convención: `"list"` para volver al listado).
#[serde(default)]
next_view: Option<String>,
},
/// Ejecuta un morphism declarado en el manifest del módulo
/// nakui-core (cuyo path vive en `Module.nakui_module_dir`).
/// Inputs (records existentes) y params (valores escalares) se
/// mapean desde los campos del form.
Morphism {
/// Nombre del morphism declarado en `nsmc.json` del manifest
/// nakui apuntado por el módulo.
name: String,
/// Mapeo `role → field_name`: por cada input declarado en
/// el `MorphismSpec.inputs`, indica qué field del form
/// contiene el UUID del record. El runtime parsea el value
/// como `Uuid` y lo pasa como input al `execute_and_log`.
///
/// Ej: `{ "stock": "stock_id", "caja": "caja_id" }` para un
/// morphism `vender` que toma roles `stock` y `caja`.
#[serde(default)]
inputs: BTreeMap<String, String>,
/// Lista de fields del form cuyos values van al `params`
/// JSON object pasado al morphism. Si está vacío, todos los
/// fields que no estén en `inputs` van a params.
#[serde(default)]
params: Vec<String>,
#[serde(default)]
next_view: Option<String>,
},
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct EntitySpec {
/// Nombre de la entity (clave en el store). Único dentro del módulo.
pub name: String,
/// Label legible (singular).
pub label: String,
/// Campos esperados en records de esta entity. Usados por el
/// runtime para inferir columnas / validar formularios.
#[serde(default)]
pub fields: Vec<FieldSpec>,
}
#[derive(Debug, Error)]
pub enum SchemaError {
#[error("io leyendo {path}: {source}")]
Io {
path: PathBuf,
#[source]
source: std::io::Error,
},
#[error("parseo de {path}: {source}")]
Parse {
path: PathBuf,
#[source]
source: serde_json::Error,
},
#[error("módulo {id}: la vista '{view}' referenciada por menu_item '{item}' no existe")]
DanglingMenuView {
id: String,
item: String,
view: String,
},
#[error("módulos con id duplicado: '{id}' aparece en {first} y {second}")]
DuplicateModuleId {
id: String,
first: PathBuf,
second: PathBuf,
},
#[error(
"módulo {id} vista '{view}': field '{field}' tiene kind=entity_ref \
pero no declaró ref_entity"
)]
EntityRefMissingTarget {
id: String,
view: String,
field: String,
},
}
impl Module {
/// Carga un module.json desde disco. Validación estructural
/// posterior (vistas referenciadas existen, etc.) la ejecuta el
/// runtime — acá sólo parseamos.
pub fn from_path(path: impl AsRef<Path>) -> Result<Self, SchemaError> {
let path = path.as_ref();
let bytes = std::fs::read(path).map_err(|source| SchemaError::Io {
path: path.to_path_buf(),
source,
})?;
serde_json::from_slice(&bytes).map_err(|source| SchemaError::Parse {
path: path.to_path_buf(),
source,
})
}
/// Validación post-parse:
/// - Cada `MenuItem.view` debe existir en `views`.
/// - Cada `FieldSpec` con `kind=EntityRef` debe declarar
/// `ref_entity`.
pub fn validate(&self) -> Result<(), SchemaError> {
for item in &self.menu {
if !self.views.contains_key(&item.view) {
return Err(SchemaError::DanglingMenuView {
id: self.id.clone(),
item: item.label.clone(),
view: item.view.clone(),
});
}
}
for (view_key, view) in &self.views {
if let View::Form(form) = view {
for f in &form.fields {
if f.kind == FieldKind::EntityRef && f.ref_entity.is_none() {
return Err(SchemaError::EntityRefMissingTarget {
id: self.id.clone(),
view: view_key.clone(),
field: f.name.clone(),
});
}
}
}
}
Ok(())
}
}
/// Carga todos los `module.json` encontrados bajo `dir` (recursivo
/// 1 nivel — espera `dir/<modulo>/module.json`). Devuelve la lista
/// ordenada por id, validada.
///
/// Falla si: I/O, parseo, módulo inválido, o ids duplicados.
pub fn load_modules_from_dir(dir: impl AsRef<Path>) -> Result<Vec<Module>, SchemaError> {
let dir = dir.as_ref();
let mut modules: Vec<(PathBuf, Module)> = Vec::new();
let entries = std::fs::read_dir(dir).map_err(|source| SchemaError::Io {
path: dir.to_path_buf(),
source,
})?;
for entry in entries.flatten() {
let p = entry.path();
if p.is_dir() {
let manifest = p.join("module.json");
if manifest.exists() {
let m = Module::from_path(&manifest)?;
m.validate()?;
modules.push((manifest, m));
}
}
}
// Detectar duplicados de id.
modules.sort_by(|a, b| a.1.id.cmp(&b.1.id));
let mut prev: Option<&(PathBuf, Module)> = None;
for cur in &modules {
if let Some(p) = prev {
if p.1.id == cur.1.id {
return Err(SchemaError::DuplicateModuleId {
id: cur.1.id.clone(),
first: p.0.clone(),
second: cur.0.clone(),
});
}
}
prev = Some(cur);
}
Ok(modules.into_iter().map(|(_, m)| m).collect())
}
#[cfg(test)]
mod tests {
use super::*;
fn sample_module() -> Module {
Module {
id: "customers".into(),
label: "Clientes".into(),
description: Some("Gestión de clientes".into()),
nakui_module_dir: None,
entities: vec![EntitySpec {
name: "customer".into(),
label: "Cliente".into(),
fields: vec![
FieldSpec {
name: "name".into(),
label: "Nombre".into(),
kind: FieldKind::Text,
default: None,
required: true,
help: None,
ref_entity: None,
},
FieldSpec {
name: "email".into(),
label: "Email".into(),
kind: FieldKind::Text,
default: None,
required: false,
help: Some("Opcional".into()),
ref_entity: None,
},
],
}],
menu: vec![
MenuItem {
label: "Listar".into(),
view: "list".into(),
icon: None,
},
MenuItem {
label: "Nuevo".into(),
view: "form".into(),
icon: None,
},
],
views: BTreeMap::from([
(
"list".into(),
View::List(ListView {
title: "Clientes".into(),
entity: "customer".into(),
columns: vec![
Column {
field: "name".into(),
label: "Nombre".into(),
weight: 2.0,
},
Column {
field: "email".into(),
label: "Email".into(),
weight: 3.0,
},
],
actions: vec![Action::OpenView {
view: "form".into(),
label: Some("Nuevo".into()),
}],
search_in: vec!["name".into(), "email".into()],
}),
),
(
"form".into(),
View::Form(FormView {
title: "Nuevo cliente".into(),
entity: "customer".into(),
fields: vec![FieldSpec {
name: "name".into(),
label: "Nombre".into(),
kind: FieldKind::Text,
default: None,
required: true,
help: None,
ref_entity: None,
}],
on_submit: Action::SeedEntity {
entity: "customer".into(),
next_view: Some("list".into()),
},
}),
),
]),
}
}
#[test]
fn module_serialize_deserialize_roundtrip() {
let m = sample_module();
let s = serde_json::to_string_pretty(&m).unwrap();
let m2: Module = serde_json::from_str(&s).unwrap();
assert_eq!(m.id, m2.id);
assert_eq!(m.menu.len(), m2.menu.len());
assert_eq!(m.views.len(), m2.views.len());
}
#[test]
fn validate_passes_for_well_formed_module() {
sample_module().validate().unwrap();
}
#[test]
fn validate_catches_dangling_menu_view() {
let mut m = sample_module();
m.menu.push(MenuItem {
label: "Roto".into(),
view: "no_existe".into(),
icon: None,
});
let err = m.validate().unwrap_err();
assert!(matches!(err, SchemaError::DanglingMenuView { .. }));
}
#[test]
fn from_path_loads_real_file() {
let tmp = tempfile::tempdir().unwrap();
let path = tmp.path().join("module.json");
std::fs::write(&path, serde_json::to_vec_pretty(&sample_module()).unwrap()).unwrap();
let m = Module::from_path(&path).unwrap();
assert_eq!(m.id, "customers");
}
#[test]
fn load_modules_from_dir_finds_subdirs() {
let tmp = tempfile::tempdir().unwrap();
let cust_dir = tmp.path().join("customers");
std::fs::create_dir(&cust_dir).unwrap();
let mut m1 = sample_module();
m1.id = "customers".into();
std::fs::write(
cust_dir.join("module.json"),
serde_json::to_vec_pretty(&m1).unwrap(),
)
.unwrap();
let prod_dir = tmp.path().join("products");
std::fs::create_dir(&prod_dir).unwrap();
let mut m2 = sample_module();
m2.id = "products".into();
std::fs::write(
prod_dir.join("module.json"),
serde_json::to_vec_pretty(&m2).unwrap(),
)
.unwrap();
let mods = load_modules_from_dir(tmp.path()).unwrap();
assert_eq!(mods.len(), 2);
assert_eq!(mods[0].id, "customers");
assert_eq!(mods[1].id, "products");
}
#[test]
fn validate_catches_entity_ref_without_target() {
let mut m = sample_module();
// Inyectamos un form con un campo EntityRef sin ref_entity.
m.views.insert(
"broken_form".into(),
View::Form(FormView {
title: "Roto".into(),
entity: "customer".into(),
fields: vec![FieldSpec {
name: "ref_to_nowhere".into(),
label: "Referencia".into(),
kind: FieldKind::EntityRef,
default: None,
required: true,
help: None,
ref_entity: None,
}],
on_submit: Action::SeedEntity {
entity: "customer".into(),
next_view: None,
},
}),
);
let err = m.validate().unwrap_err();
assert!(
matches!(err, SchemaError::EntityRefMissingTarget { .. }),
"got: {err:?}"
);
}
#[test]
fn entity_ref_with_target_validates_clean() {
let mut m = sample_module();
m.views.insert(
"ok_form".into(),
View::Form(FormView {
title: "OK".into(),
entity: "customer".into(),
fields: vec![FieldSpec {
name: "supplier".into(),
label: "Proveedor".into(),
kind: FieldKind::EntityRef,
default: None,
required: true,
help: None,
ref_entity: Some("supplier".into()),
}],
on_submit: Action::SeedEntity {
entity: "customer".into(),
next_view: None,
},
}),
);
m.menu.push(MenuItem {
label: "OK".into(),
view: "ok_form".into(),
icon: None,
});
m.validate().unwrap();
}
#[test]
fn load_modules_detects_duplicate_id() {
let tmp = tempfile::tempdir().unwrap();
let a_dir = tmp.path().join("a");
let b_dir = tmp.path().join("b");
std::fs::create_dir_all(&a_dir).unwrap();
std::fs::create_dir_all(&b_dir).unwrap();
let m = sample_module(); // id = "customers"
std::fs::write(
a_dir.join("module.json"),
serde_json::to_vec(&m).unwrap(),
)
.unwrap();
std::fs::write(
b_dir.join("module.json"),
serde_json::to_vec(&m).unwrap(),
)
.unwrap();
let err = load_modules_from_dir(tmp.path()).unwrap_err();
assert!(matches!(err, SchemaError::DuplicateModuleId { .. }));
}
}
@@ -0,0 +1,119 @@
//! Validación de los 6 módulos demo en `examples/nakui-modules/`.
//!
//! Si esto verde, garantizamos que un usuario que clone el repo y
//! corra `NAKUI_MODULES_DIR=examples/nakui-modules cargo run -p nakui-ui`
//! va a obtener los 6 módulos cargados sin tocar nada.
use nahual_meta_schema::{load_modules_from_dir, FieldKind, View};
fn examples_dir() -> std::path::PathBuf {
// Tests corren desde el dir del crate; el repo root está dos
// niveles arriba: crates/modules/nakui/ui-schema → repo.
// Tras el lift a nahual, el crate vive en
// `crates/modules/ui_engine/libs/meta-schema`, así que el repo
// root queda 5 niveles arriba.
let here = std::path::Path::new(env!("CARGO_MANIFEST_DIR"));
here.join("../../../../..").join("examples/nakui-modules")
}
#[test]
fn loads_all_demo_modules() {
let dir = examples_dir();
let mods = load_modules_from_dir(&dir).unwrap_or_else(|e| {
panic!("load failed for {}: {e}", dir.display());
});
let ids: Vec<&str> = mods.iter().map(|m| m.id.as_str()).collect();
assert_eq!(
ids,
vec![
"customers",
"inventory_movements",
"invoices",
"products",
"sales_engine",
"sales_orders",
"suppliers",
],
"expected 7 modules in alphabetical order \
(sales_engine se sumó al wirear Action::Morphism)"
);
}
#[test]
fn sales_engine_declares_nakui_module_dir_and_morphism() {
// Sanity del módulo demo de morphism: nakui_module_dir set,
// y al menos una vista con Action::Morphism en su on_submit.
let mods = load_modules_from_dir(examples_dir()).unwrap();
let sales = mods
.iter()
.find(|m| m.id == "sales_engine")
.expect("sales_engine debe estar");
assert!(
sales.nakui_module_dir.is_some(),
"sales_engine debería declarar nakui_module_dir"
);
let has_morphism_view = sales.views.values().any(|v| match v {
nahual_meta_schema::View::Form(form) => {
matches!(form.on_submit, nahual_meta_schema::Action::Morphism { .. })
}
_ => false,
});
assert!(
has_morphism_view,
"sales_engine debería tener al menos una Form con Action::Morphism"
);
}
#[test]
fn every_demo_module_has_list_and_form_views() {
let mods = load_modules_from_dir(examples_dir()).unwrap();
for m in &mods {
let mut has_list = false;
let mut has_form = false;
for v in m.views.values() {
match v {
View::List(_) => has_list = true,
View::Form(_) => has_form = true,
}
}
assert!(
has_list && has_form,
"module {} should expose at least one list + one form view",
m.id
);
}
}
#[test]
fn every_demo_form_field_kind_is_recognized() {
// Sanity: ningún módulo demo usa un kind que no esté en el enum
// (sería rechazado al parsear, pero check explícito no daña).
let mods = load_modules_from_dir(examples_dir()).unwrap();
for m in &mods {
for v in m.views.values() {
if let View::Form(form) = v {
for f in &form.fields {
let _ok = matches!(
f.kind,
FieldKind::Text
| FieldKind::Multiline
| FieldKind::Number
| FieldKind::Boolean
| FieldKind::Date
);
}
}
}
}
}
#[test]
fn every_module_validates_clean() {
// validate() chequea que cada MenuItem.view exista en views.
// Un typo en cualquiera de los 6 módulos haría fallar este test.
let mods = load_modules_from_dir(examples_dir()).unwrap();
for m in &mods {
m.validate()
.unwrap_or_else(|e| panic!("module {} failed validate: {e}", m.id));
}
}
@@ -0,0 +1,13 @@
[package]
name = "nahual-provider-fs"
version = { workspace = true }
edition = { workspace = true }
license = { workspace = true }
description = "DataProvider de filesystem local con discernimiento de contenido (shipote-discern)."
[dependencies]
nahual-core = { workspace = true }
async-trait = { workspace = true }
tokio = { workspace = true }
notify = { workspace = true }
shuma-discern = { path = "../../../../shuma/shuma-discern" }
@@ -0,0 +1,119 @@
//! Provider de filesystem local. Crate puro: cero dependencia de UI.
//! Implementa `nahual_core::DataProvider` listando hijos de un path con
//! `std::fs::read_dir` y leyendo archivos a `Vec<u8>` via `tokio::io`.
use async_trait::async_trait;
use shuma_discern::{DiscernPipeline, Hint};
use std::fs;
use std::io::{Cursor, Read};
use std::path::Path;
use std::pin::Pin;
use std::sync::Arc;
use tokio::io::{AsyncRead, AsyncWrite};
use nahual_core::{DataProvider, DisplayType, EntityNode};
pub const PROVIDER_ID: &str = "local_fs";
/// Bytes que samplea el discerner por archivo. 4 KiB cubre headers de
/// formatos comunes (PNG, ELF, JSON/TOML hasta una clave de profundidad
/// razonable) sin saturar I/O al expandir un directorio.
const DISCERN_SAMPLE_BYTES: usize = 4096;
/// Tamaño máximo de archivo que sampleamos. Archivos más grandes se
/// discernen igual via los primeros 4 KiB: el `seek/read` siempre lee
/// head, y el costo es O(SAMPLE) sin importar el size total.
/// Mantenemos esta constante por documentación; no se usa para skipear.
const _DISCERN_SAMPLE_DOC: () = ();
pub struct FileDataProvider {
discerner: Arc<DiscernPipeline>,
}
impl FileDataProvider {
pub fn new() -> Self {
Self {
discerner: Arc::new(DiscernPipeline::default_pipeline()),
}
}
}
impl Default for FileDataProvider {
fn default() -> Self {
Self::new()
}
}
#[async_trait]
impl DataProvider for FileDataProvider {
fn provider_id(&self) -> String {
PROVIDER_ID.to_string()
}
async fn list_children(&self, parent_id: Option<&str>) -> Result<Vec<EntityNode>, String> {
let path = parent_id.unwrap_or(".");
let mut children = Vec::new();
if let Ok(entries) = fs::read_dir(path) {
for entry in entries.filter_map(|e| e.ok()) {
let path = entry.path();
let name = path
.file_name()
.unwrap_or_default()
.to_string_lossy()
.into_owned();
let is_dir = path.is_dir();
let display_type = if is_dir { DisplayType::Folder } else { DisplayType::File };
// Discernimos sólo archivos. Folders no tienen MIME útil.
let mime_type = if is_dir {
None
} else {
discern_head(&path, &self.discerner)
};
children.push(EntityNode {
id: path.to_string_lossy().into_owned(),
name,
display_type,
mime_type,
});
}
}
Ok(children)
}
async fn get_read_stream(
&self,
entity_id: &str,
) -> Result<Pin<Box<dyn AsyncRead + Send>>, String> {
let content = fs::read(Path::new(entity_id)).map_err(|e| e.to_string())?;
Ok(Box::pin(Cursor::new(content)))
}
async fn get_write_stream(
&self,
_entity_id: &str,
) -> Result<Pin<Box<dyn AsyncWrite + Send>>, String> {
Err("Escritura en streaming no implementada para FS".to_string())
}
}
/// Lee el head del archivo y lo pasa por el DiscernPipeline. Devuelve el
/// MIME detectado (si alguno) o `None` si no hubo match.
///
/// Sync intencional: estamos dentro del runtime que ya es async, pero la
/// lectura es de tamaño fijo (4 KiB) y va a page cache; el costo de
/// `tokio::fs` no compensaría para esto.
fn discern_head(path: &Path, discerner: &DiscernPipeline) -> Option<String> {
let mut buf = vec![0u8; DISCERN_SAMPLE_BYTES];
let mut f = fs::File::open(path).ok()?;
let n = f.read(&mut buf).ok()?;
buf.truncate(n);
let path_str = path.to_str();
let hint = Hint {
path: path_str,
size_total: None,
};
discerner.discern(&buf, &hint).and_then(|d| d.mime)
}
@@ -0,0 +1,12 @@
[package]
name = "nahual-provider-sqlite"
version = { workspace = true }
edition = { workspace = true }
license = { workspace = true }
description = "DataProvider de SQLite (jerarquía vía parent_id)."
[dependencies]
nahual-core = { workspace = true }
async-trait = { workspace = true }
tokio = { workspace = true }
rusqlite = { workspace = true }
@@ -0,0 +1,118 @@
//! Provider de SQLite. Crate puro: cero dependencia de UI.
//! Tabla `items(id, parent_id, name, display_type, content)` con jerarquía
//! por `parent_id NULL` = raíz.
use async_trait::async_trait;
use rusqlite::Connection;
use std::io::Cursor;
use std::pin::Pin;
use std::sync::{Arc, Mutex};
use tokio::io::{AsyncRead, AsyncWrite};
use nahual_core::{DataProvider, DisplayType, EntityNode};
pub const PROVIDER_ID: &str = "sqlite_db";
pub struct SqliteDataProvider {
db: Arc<Mutex<Connection>>,
}
impl SqliteDataProvider {
pub fn new(path: &str) -> Result<Self, String> {
let conn = Connection::open(path).map_err(|e| e.to_string())?;
conn.execute(
"CREATE TABLE IF NOT EXISTS items (
id TEXT PRIMARY KEY,
parent_id TEXT,
name TEXT NOT NULL,
display_type TEXT NOT NULL,
content BLOB
)",
[],
)
.map_err(|e| e.to_string())?;
// Seed mínimo si la tabla está vacía — para que el DatabaseExplorer
// tenga algo que mostrar en una primera ejecución sin pre-config.
let count: i64 = conn
.query_row("SELECT COUNT(*) FROM items", [], |row| row.get(0))
.unwrap_or(0);
if count == 0 {
let _ = conn.execute(
"INSERT INTO items (id, parent_id, name, display_type, content) VALUES \
('readme', NULL, 'README.md', 'File', ?), \
('notes', NULL, 'notes', 'Folder', NULL), \
('todo', 'notes', 'TODO.md', 'File', ?)",
rusqlite::params![
b"# Yahweh\n\nDemo readme stored in SQLite.\n",
b"- TreeView gen\xC3\xA9rico\n- containers swappables\n- layout JSON\n",
],
);
}
Ok(Self {
db: Arc::new(Mutex::new(conn)),
})
}
}
#[async_trait]
impl DataProvider for SqliteDataProvider {
fn provider_id(&self) -> String {
PROVIDER_ID.to_string()
}
async fn list_children(&self, parent_id: Option<&str>) -> Result<Vec<EntityNode>, String> {
let db = self.db.lock().unwrap();
let mut stmt = db
.prepare("SELECT id, name, display_type FROM items WHERE parent_id IS ?")
.map_err(|e| e.to_string())?;
let rows = stmt
.query_map([parent_id], |row| {
let display_type_str: String = row.get(2)?;
let display_type = match display_type_str.as_str() {
"Folder" => DisplayType::Folder,
"Stream" => DisplayType::Stream,
_ => DisplayType::File,
};
Ok(EntityNode {
id: row.get(0)?,
name: row.get(1)?,
display_type,
mime_type: None,
})
})
.map_err(|e| e.to_string())?;
let mut children = Vec::new();
for row in rows {
children.push(row.map_err(|e| e.to_string())?);
}
Ok(children)
}
async fn get_read_stream(
&self,
entity_id: &str,
) -> Result<Pin<Box<dyn AsyncRead + Send>>, String> {
let db = self.db.lock().unwrap();
let content: Vec<u8> = db
.query_row(
"SELECT content FROM items WHERE id = ?",
[entity_id],
|row| row.get(0),
)
.map_err(|e| e.to_string())?;
Ok(Box::pin(Cursor::new(content)))
}
async fn get_write_stream(
&self,
_entity_id: &str,
) -> Result<Pin<Box<dyn AsyncWrite + Send>>, String> {
Err("Escritura en streaming no implementada para SQLite (todavía)".to_string())
}
}
@@ -0,0 +1,9 @@
[package]
name = "nahual-theme"
version = { workspace = true }
edition = { workspace = true }
license = { workspace = true }
description = "Sistema de temas — paleta + gradientes, Theme como Global GPUI."
[dependencies]
gpui = { workspace = true }
+616
View File
@@ -0,0 +1,616 @@
//! `nahual_theme` — paleta de colores y backgrounds compartidos.
//!
//! El `Theme` se instala como `Global` de GPUI. Los widgets lo leen vía
//! `cx.global::<Theme>()` durante su `render`, y se subscriben con
//! `cx.observe_global::<Theme>(…)` para auto-redibujarse cuando cambia.
//!
//! Filosofía: el theme es **dato puro** (sin lógica de UI). No conoce a
//! ningún widget concreto. Cada widget pide slots semánticos (panel_bg,
//! row_hover, accent…) sin acoplarse a colores hex específicos.
use gpui::{Background, Global, Hsla, hsla, linear_color_stop, linear_gradient};
/// Paleta semántica del theme. Cada slot tiene un nombre funcional, no
/// cromático — así los widgets piden "fondo de panel" sin acoplarse a
/// "azul oscuro".
///
/// Convención de slots:
/// - `bg_*` se devuelve como `Background` (soporta gradientes); los widgets
/// lo pasan a `.bg(...)` directamente.
/// - `fg_*`, `accent`, `border` son `Hsla` (colores planos para texto y
/// ornamentos).
/// - `bg_row_*` son `Hsla` porque las filas de una lista virtualizada se
/// beneficiarían poco de un gradiente individual.
#[derive(Clone, Debug)]
pub struct Theme {
pub name: &'static str,
pub is_dark: bool,
// Fondos.
pub bg_app: Background,
pub bg_panel: Background,
pub bg_panel_alt: Background,
pub bg_row_hover: Hsla,
pub bg_row_active: Hsla,
// Foregrounds.
pub fg_text: Hsla,
pub fg_muted: Hsla,
pub fg_disabled: Hsla,
// Acentos y ornamentos.
pub accent: Hsla,
pub accent_strong: Hsla,
pub border: Hsla,
pub border_strong: Hsla,
/// Marker colors para indicar "este file está abierto en container N".
/// Paleta circular — el N-ésimo container usa `marker_palette[n % len]`.
pub marker_palette: Vec<Hsla>,
}
impl Global for Theme {}
/// Helper privado: deriva los 5 slots "ornament secundario"
/// (bg_input/button/button_hover + accent_destructive +
/// bg_destructive_hover) según `is_dark`.
///
/// Devuelve los slots en el orden de los métodos públicos del
/// `Theme`. Los métodos del impl los exponen individualmente.
fn ornament_slots(is_dark: bool) -> (Hsla, Hsla, Hsla, Hsla, Hsla) {
if is_dark {
(
// bg_input: muy oscuro, sutil tinte azul/gris
hsla(220.0 / 360.0, 0.20, 0.07, 1.0),
// bg_button: medio oscuro
hsla(220.0 / 360.0, 0.18, 0.20, 1.0),
// bg_button_hover: un poco más claro
hsla(220.0 / 360.0, 0.20, 0.27, 1.0),
// accent_destructive: rojo medio-claro para visibilidad
hsla(0.0, 0.55, 0.65, 1.0),
// bg_destructive_hover: rojo oscuro de fondo
hsla(0.0, 0.55, 0.18, 1.0),
)
} else {
(
hsla(220.0 / 360.0, 0.10, 0.97, 1.0),
hsla(220.0 / 360.0, 0.15, 0.85, 1.0),
hsla(220.0 / 360.0, 0.20, 0.75, 1.0),
hsla(0.0, 0.65, 0.45, 1.0),
hsla(0.0, 0.55, 0.92, 1.0),
)
}
}
impl Theme {
/// Bg sutil para fields editables que se quieren marcar como
/// "input target" sin ser un panel. Derivado de `is_dark`.
pub fn bg_input(&self) -> Hsla {
ornament_slots(self.is_dark).0
}
/// Bg para clickable controls (botones secundarios, edit/delete
/// icons en filas). Más prominente que `bg_panel_alt`, menos que
/// `accent`. Derivado de `is_dark`.
pub fn bg_button(&self) -> Hsla {
ornament_slots(self.is_dark).1
}
/// Hover de [`Self::bg_button`].
pub fn bg_button_hover(&self) -> Hsla {
ornament_slots(self.is_dark).2
}
/// Accent rojo para acciones destructivas (delete, drop, force).
pub fn accent_destructive(&self) -> Hsla {
ornament_slots(self.is_dark).3
}
/// Bg de hover sobre clickable destructive elements (icon ✕,
/// botones de "borrar"). Más oscuro que `accent_destructive`.
pub fn bg_destructive_hover(&self) -> Hsla {
ornament_slots(self.is_dark).4
}
pub fn global(cx: &gpui::App) -> &Self {
cx.global::<Self>()
}
/// Carga el theme persistido si existe + es válido; sino default
/// a Nebula. El persisted lo escribe [`Self::set`] cada vez que el
/// theme cambia (típicamente vía `theme_switcher`).
pub fn install_default(cx: &mut gpui::App) {
let theme = load_persisted().unwrap_or_else(Self::nebula);
cx.set_global(theme);
}
/// Reemplaza el theme global y persiste su `name` al config file.
/// GPUI notifica a todos los `observe_global` suscriptores en el
/// siguiente frame.
///
/// La persistencia es best-effort: si write falla (no hay home,
/// permission denied, etc.), el theme cambia in-memory pero no
/// sobrevive al restart. No se rebota — la UX no se interrumpe
/// por un I/O secundario.
pub fn set(cx: &mut gpui::App, theme: Self) {
let _ = persist(&theme);
cx.set_global(theme);
}
/// Lista todos los presets en orden estable. Usado por el switcher para
/// ciclar y por el menú de "Tema" cuando lo agreguemos.
pub fn all() -> Vec<Self> {
vec![
Self::nebula(),
Self::aurora(),
Self::sunset(),
Self::flat_dark(),
Self::solarized_light(),
Self::high_contrast(),
Self::print_color(),
Self::print_bw(),
]
}
/// Devuelve el preset cuyo `name` matchea (case-insensitive). `None` si
/// el nombre no existe — útil para validar input de usuario al cargar
/// preferencias persistidas.
pub fn by_name(name: &str) -> Option<Self> {
Self::all()
.into_iter()
.find(|t| t.name.eq_ignore_ascii_case(name))
}
/// Próximo preset en la rotación de `all()`. Si `current` no está, se
/// vuelve al primero. La rotación es circular (último → primero).
pub fn next_after(current: &str) -> Self {
let all = Self::all();
let idx = all.iter().position(|t| t.name == current);
match idx {
Some(i) => all[(i + 1) % all.len()].clone(),
None => all[0].clone(),
}
}
// =====================================================================
// Presets
// =====================================================================
/// **Nebula** — default. Gradiente vertical violáceo profundo → teal
/// medianoche. Pensado para sentirse moderno y descansado de noche.
pub fn nebula() -> Self {
let bg_app = linear_gradient(
165.0,
linear_color_stop(hsla(265.0 / 360.0, 0.38, 0.07, 1.0), 0.0),
linear_color_stop(hsla(195.0 / 360.0, 0.42, 0.09, 1.0), 1.0),
);
let bg_panel = linear_gradient(
165.0,
linear_color_stop(hsla(245.0 / 360.0, 0.28, 0.10, 1.0), 0.0),
linear_color_stop(hsla(210.0 / 360.0, 0.30, 0.12, 1.0), 1.0),
);
let bg_panel_alt = linear_gradient(
165.0,
linear_color_stop(hsla(255.0 / 360.0, 0.25, 0.13, 1.0), 0.0),
linear_color_stop(hsla(220.0 / 360.0, 0.27, 0.14, 1.0), 1.0),
);
Self {
name: "Nebula",
is_dark: true,
bg_app,
bg_panel,
bg_panel_alt,
bg_row_hover: hsla(220.0 / 360.0, 0.30, 0.20, 0.45),
bg_row_active: hsla(280.0 / 360.0, 0.55, 0.28, 0.65),
fg_text: hsla(210.0 / 360.0, 0.35, 0.88, 1.0),
fg_muted: hsla(215.0 / 360.0, 0.22, 0.58, 1.0),
fg_disabled: hsla(215.0 / 360.0, 0.10, 0.40, 1.0),
accent: hsla(280.0 / 360.0, 0.65, 0.65, 1.0),
accent_strong: hsla(285.0 / 360.0, 0.78, 0.74, 1.0),
border: hsla(225.0 / 360.0, 0.20, 0.22, 1.0),
border_strong: hsla(280.0 / 360.0, 0.40, 0.45, 1.0),
marker_palette: vec![
hsla(280.0 / 360.0, 0.65, 0.55, 0.45),
hsla(195.0 / 360.0, 0.65, 0.50, 0.45),
hsla(35.0 / 360.0, 0.75, 0.55, 0.45),
hsla(135.0 / 360.0, 0.55, 0.50, 0.45),
hsla(0.0, 0.60, 0.55, 0.45),
],
}
}
/// **Aurora** — verdes-cian-azul, evoca aurora boreal. Más frío que
/// Nebula, contraste alto.
pub fn aurora() -> Self {
let bg_app = linear_gradient(
190.0,
linear_color_stop(hsla(170.0 / 360.0, 0.45, 0.06, 1.0), 0.0),
linear_color_stop(hsla(220.0 / 360.0, 0.50, 0.09, 1.0), 1.0),
);
let bg_panel = linear_gradient(
190.0,
linear_color_stop(hsla(165.0 / 360.0, 0.32, 0.10, 1.0), 0.0),
linear_color_stop(hsla(215.0 / 360.0, 0.36, 0.12, 1.0), 1.0),
);
let bg_panel_alt = linear_gradient(
190.0,
linear_color_stop(hsla(170.0 / 360.0, 0.30, 0.13, 1.0), 0.0),
linear_color_stop(hsla(220.0 / 360.0, 0.32, 0.15, 1.0), 1.0),
);
Self {
name: "Aurora",
is_dark: true,
bg_app,
bg_panel,
bg_panel_alt,
bg_row_hover: hsla(180.0 / 360.0, 0.40, 0.22, 0.50),
bg_row_active: hsla(160.0 / 360.0, 0.55, 0.30, 0.65),
fg_text: hsla(180.0 / 360.0, 0.20, 0.92, 1.0),
fg_muted: hsla(185.0 / 360.0, 0.18, 0.62, 1.0),
fg_disabled: hsla(185.0 / 360.0, 0.10, 0.40, 1.0),
accent: hsla(150.0 / 360.0, 0.70, 0.55, 1.0),
accent_strong: hsla(160.0 / 360.0, 0.85, 0.65, 1.0),
border: hsla(195.0 / 360.0, 0.25, 0.20, 1.0),
border_strong: hsla(160.0 / 360.0, 0.55, 0.45, 1.0),
marker_palette: vec![
hsla(150.0 / 360.0, 0.75, 0.50, 0.45),
hsla(195.0 / 360.0, 0.70, 0.50, 0.45),
hsla(225.0 / 360.0, 0.70, 0.55, 0.45),
hsla(85.0 / 360.0, 0.65, 0.50, 0.45),
hsla(330.0 / 360.0, 0.65, 0.55, 0.45),
],
}
}
/// **Sunset** — naranjas-rosas-violetas profundos. Cálido, alto contraste
/// con texto claro.
pub fn sunset() -> Self {
let bg_app = linear_gradient(
170.0,
linear_color_stop(hsla(20.0 / 360.0, 0.50, 0.08, 1.0), 0.0),
linear_color_stop(hsla(310.0 / 360.0, 0.45, 0.10, 1.0), 1.0),
);
let bg_panel = linear_gradient(
170.0,
linear_color_stop(hsla(15.0 / 360.0, 0.32, 0.12, 1.0), 0.0),
linear_color_stop(hsla(315.0 / 360.0, 0.30, 0.13, 1.0), 1.0),
);
let bg_panel_alt = linear_gradient(
170.0,
linear_color_stop(hsla(20.0 / 360.0, 0.30, 0.15, 1.0), 0.0),
linear_color_stop(hsla(320.0 / 360.0, 0.28, 0.16, 1.0), 1.0),
);
Self {
name: "Sunset",
is_dark: true,
bg_app,
bg_panel,
bg_panel_alt,
bg_row_hover: hsla(25.0 / 360.0, 0.40, 0.25, 0.45),
bg_row_active: hsla(5.0 / 360.0, 0.55, 0.32, 0.65),
fg_text: hsla(30.0 / 360.0, 0.30, 0.92, 1.0),
fg_muted: hsla(25.0 / 360.0, 0.20, 0.62, 1.0),
fg_disabled: hsla(25.0 / 360.0, 0.10, 0.42, 1.0),
accent: hsla(15.0 / 360.0, 0.78, 0.62, 1.0),
accent_strong: hsla(355.0 / 360.0, 0.85, 0.68, 1.0),
border: hsla(15.0 / 360.0, 0.25, 0.25, 1.0),
border_strong: hsla(355.0 / 360.0, 0.55, 0.45, 1.0),
marker_palette: vec![
hsla(15.0 / 360.0, 0.80, 0.55, 0.45),
hsla(310.0 / 360.0, 0.65, 0.55, 0.45),
hsla(45.0 / 360.0, 0.80, 0.55, 0.45),
hsla(285.0 / 360.0, 0.65, 0.60, 0.45),
hsla(355.0 / 360.0, 0.70, 0.55, 0.45),
],
}
}
/// **Flat Dark** — sin gradientes, paleta cool gris-azulado. Para quien
/// prefiere monocromía. Útil para contrastar visualmente con los temas
/// de gradiente.
pub fn flat_dark() -> Self {
let bg_app: Background = hsla(220.0 / 360.0, 0.15, 0.09, 1.0).into();
let bg_panel: Background = hsla(220.0 / 360.0, 0.15, 0.12, 1.0).into();
let bg_panel_alt: Background = hsla(220.0 / 360.0, 0.15, 0.14, 1.0).into();
Self {
name: "Flat Dark",
is_dark: true,
bg_app,
bg_panel,
bg_panel_alt,
bg_row_hover: hsla(220.0 / 360.0, 0.20, 0.20, 1.0),
bg_row_active: hsla(220.0 / 360.0, 0.40, 0.30, 1.0),
fg_text: hsla(210.0 / 360.0, 0.20, 0.85, 1.0),
fg_muted: hsla(215.0 / 360.0, 0.15, 0.55, 1.0),
fg_disabled: hsla(215.0 / 360.0, 0.10, 0.40, 1.0),
accent: hsla(210.0 / 360.0, 0.70, 0.55, 1.0),
accent_strong: hsla(210.0 / 360.0, 0.85, 0.65, 1.0),
border: hsla(220.0 / 360.0, 0.15, 0.20, 1.0),
border_strong: hsla(220.0 / 360.0, 0.30, 0.35, 1.0),
marker_palette: vec![
hsla(210.0 / 360.0, 0.65, 0.55, 0.40),
hsla(160.0 / 360.0, 0.55, 0.50, 0.40),
hsla(30.0 / 360.0, 0.75, 0.55, 0.40),
hsla(0.0, 0.55, 0.55, 0.40),
],
}
}
/// **Solarized Light** — preset claro inspirado en la paleta clásica de
/// Schoonover. Sin gradientes (en light un gradiente sutil pasa
/// desapercibido y solo introduce ruido).
pub fn solarized_light() -> Self {
let bg_app: Background = hsla(44.0 / 360.0, 0.87, 0.94, 1.0).into();
let bg_panel: Background = hsla(46.0 / 360.0, 0.42, 0.88, 1.0).into();
let bg_panel_alt: Background = hsla(46.0 / 360.0, 0.42, 0.92, 1.0).into();
Self {
name: "Solarized Light",
is_dark: false,
bg_app,
bg_panel,
bg_panel_alt,
bg_row_hover: hsla(46.0 / 360.0, 0.45, 0.80, 0.65),
bg_row_active: hsla(45.0 / 360.0, 0.55, 0.72, 0.85),
fg_text: hsla(196.0 / 360.0, 0.13, 0.30, 1.0),
fg_muted: hsla(196.0 / 360.0, 0.13, 0.45, 1.0),
fg_disabled: hsla(196.0 / 360.0, 0.10, 0.62, 1.0),
accent: hsla(205.0 / 360.0, 0.69, 0.42, 1.0),
accent_strong: hsla(205.0 / 360.0, 0.82, 0.38, 1.0),
border: hsla(46.0 / 360.0, 0.30, 0.78, 1.0),
border_strong: hsla(205.0 / 360.0, 0.40, 0.55, 1.0),
marker_palette: vec![
hsla(205.0 / 360.0, 0.69, 0.42, 0.30),
hsla(175.0 / 360.0, 0.74, 0.32, 0.30),
hsla(45.0 / 360.0, 1.00, 0.36, 0.30),
hsla(331.0 / 360.0, 0.74, 0.42, 0.30),
hsla(18.0 / 360.0, 0.89, 0.40, 0.30),
],
}
}
/// **Print Color** — preview de impresión a color sobre papel.
/// Fondo crema cálido (#f7f4ea-ish), texto y ornamentos en
/// luminancias bajas para que sobrevivan ink-bleed. Sin gradientes
/// (los gradients no imprimen bien) y sin glow.
pub fn print_color() -> Self {
let bg_app: Background = hsla(42.0 / 360.0, 0.30, 0.94, 1.0).into();
let bg_panel: Background = hsla(40.0 / 360.0, 0.25, 0.97, 1.0).into();
let bg_panel_alt: Background = hsla(40.0 / 360.0, 0.20, 0.92, 1.0).into();
Self {
name: "Print Color",
is_dark: false,
bg_app,
bg_panel,
bg_panel_alt,
bg_row_hover: hsla(40.0 / 360.0, 0.30, 0.86, 0.70),
bg_row_active: hsla(35.0 / 360.0, 0.45, 0.78, 0.85),
fg_text: hsla(30.0 / 360.0, 0.15, 0.18, 1.0),
fg_muted: hsla(30.0 / 360.0, 0.12, 0.40, 1.0),
fg_disabled: hsla(30.0 / 360.0, 0.08, 0.62, 1.0),
accent: hsla(15.0 / 360.0, 0.70, 0.40, 1.0),
accent_strong: hsla(355.0 / 360.0, 0.78, 0.36, 1.0),
border: hsla(40.0 / 360.0, 0.22, 0.82, 1.0),
border_strong: hsla(30.0 / 360.0, 0.30, 0.55, 1.0),
marker_palette: vec![
hsla(15.0 / 360.0, 0.70, 0.35, 0.30),
hsla(210.0 / 360.0, 0.65, 0.35, 0.30),
hsla(140.0 / 360.0, 0.55, 0.30, 0.30),
hsla(285.0 / 360.0, 0.55, 0.38, 0.30),
hsla(40.0 / 360.0, 0.85, 0.40, 0.30),
],
}
}
/// **Print B&W** — preview de impresión monocromática. Fondo
/// blanco puro, todo en escala de grises. Cualquier slot que
/// dependa de "color" en widgets astrológicos se diferencia por
/// forma o por dash pattern, no por tinte.
pub fn print_bw() -> Self {
let bg_app: Background = hsla(0.0, 0.0, 1.00, 1.0).into();
let bg_panel: Background = hsla(0.0, 0.0, 0.99, 1.0).into();
let bg_panel_alt: Background = hsla(0.0, 0.0, 0.95, 1.0).into();
Self {
name: "Print B&W",
is_dark: false,
bg_app,
bg_panel,
bg_panel_alt,
bg_row_hover: hsla(0.0, 0.0, 0.88, 0.85),
bg_row_active: hsla(0.0, 0.0, 0.78, 0.95),
fg_text: hsla(0.0, 0.0, 0.10, 1.0),
fg_muted: hsla(0.0, 0.0, 0.40, 1.0),
fg_disabled: hsla(0.0, 0.0, 0.65, 1.0),
accent: hsla(0.0, 0.0, 0.20, 1.0),
accent_strong: hsla(0.0, 0.0, 0.05, 1.0),
border: hsla(0.0, 0.0, 0.80, 1.0),
border_strong: hsla(0.0, 0.0, 0.40, 1.0),
marker_palette: vec![
hsla(0.0, 0.0, 0.30, 0.35),
hsla(0.0, 0.0, 0.50, 0.35),
hsla(0.0, 0.0, 0.20, 0.35),
hsla(0.0, 0.0, 0.60, 0.35),
],
}
}
/// **High Contrast** — accesibilidad. Negro puro con texto blanco y
/// ornamentos amarillo/verde fuertes. Suficientemente diferente para
/// notar inmediatamente al usar el switcher.
pub fn high_contrast() -> Self {
let bg_app: Background = hsla(0.0, 0.0, 0.0, 1.0).into();
let bg_panel: Background = hsla(0.0, 0.0, 0.05, 1.0).into();
let bg_panel_alt: Background = hsla(0.0, 0.0, 0.10, 1.0).into();
Self {
name: "High Contrast",
is_dark: true,
bg_app,
bg_panel,
bg_panel_alt,
bg_row_hover: hsla(60.0 / 360.0, 1.00, 0.50, 0.35),
bg_row_active: hsla(120.0 / 360.0, 1.00, 0.40, 0.55),
fg_text: hsla(0.0, 0.0, 1.0, 1.0),
fg_muted: hsla(0.0, 0.0, 0.75, 1.0),
fg_disabled: hsla(0.0, 0.0, 0.50, 1.0),
accent: hsla(60.0 / 360.0, 1.00, 0.60, 1.0),
accent_strong: hsla(60.0 / 360.0, 1.00, 0.75, 1.0),
border: hsla(0.0, 0.0, 0.30, 1.0),
border_strong: hsla(60.0 / 360.0, 1.00, 0.60, 1.0),
marker_palette: vec![
hsla(60.0 / 360.0, 1.00, 0.55, 0.50),
hsla(120.0 / 360.0, 1.00, 0.50, 0.50),
hsla(180.0 / 360.0, 1.00, 0.55, 0.50),
hsla(0.0, 1.00, 0.60, 0.50),
hsla(300.0 / 360.0, 1.00, 0.65, 0.50),
],
}
}
}
// ============================================================================
// Persistencia de la preferencia de theme
// ============================================================================
use std::path::{Path, PathBuf};
const CONFIG_SUBDIR: &str = "nahual";
const CONFIG_FILE: &str = "theme";
/// Path al archivo donde se persiste la preferencia de theme.
///
/// Convención XDG: `$XDG_CONFIG_HOME/nahual/theme` si está set;
/// sino `$HOME/.config/nahual/theme`. `None` si ni `XDG_CONFIG_HOME`
/// ni `HOME` están definidos (típicamente en sandboxes / CI).
pub fn config_path() -> Option<PathBuf> {
let base = std::env::var("XDG_CONFIG_HOME")
.ok()
.filter(|s| !s.is_empty())
.map(PathBuf::from)
.or_else(|| {
std::env::var("HOME")
.ok()
.filter(|s| !s.is_empty())
.map(|h| PathBuf::from(h).join(".config"))
})?;
Some(base.join(CONFIG_SUBDIR).join(CONFIG_FILE))
}
/// Lee el theme persistido. `None` si: no hay config dir, file no
/// existe, lectura falla, o el name guardado no matchea ningún
/// preset (ej. tema renombrado entre versiones).
pub fn load_persisted() -> Option<Theme> {
let path = config_path()?;
load_from_path(&path)
}
/// Persiste el name del theme al config file. Crea el dir parent si
/// no existe. Devuelve el `io::Error` para que tests puedan
/// asertar; los call sites de producción pueden ignorarlo
/// (best-effort persistence).
pub fn persist(theme: &Theme) -> std::io::Result<()> {
let path = config_path().ok_or_else(|| {
std::io::Error::new(
std::io::ErrorKind::NotFound,
"no se pudo determinar config dir (HOME/XDG_CONFIG_HOME no set)",
)
})?;
persist_to_path(theme, &path)
}
/// Variante de [`load_persisted`] que toma un path explícito. Útil
/// para tests + para apps que quieren su propio path
/// (ej. multi-user single-machine).
pub fn load_from_path(path: &Path) -> Option<Theme> {
let raw = std::fs::read_to_string(path).ok()?;
let name = raw.trim();
Theme::by_name(name)
}
/// Variante de [`persist`] que toma un path explícito.
pub fn persist_to_path(theme: &Theme, path: &Path) -> std::io::Result<()> {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
std::fs::write(path, theme.name)
}
#[cfg(test)]
mod persistence_tests {
use super::*;
fn unique_path(label: &str) -> PathBuf {
let mut p = std::env::temp_dir();
p.push(format!(
"nahual-theme-test-{}-{}-{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_nanos())
.unwrap_or(0),
label
));
p
}
#[test]
fn persist_then_load_round_trip() {
let path = unique_path("round-trip");
let theme = Theme::aurora();
persist_to_path(&theme, &path).unwrap();
let loaded = load_from_path(&path).expect("load");
assert_eq!(loaded.name, "Aurora");
let _ = std::fs::remove_file(&path);
let _ = std::fs::remove_dir(path.parent().unwrap());
}
#[test]
fn load_from_missing_file_returns_none() {
let path = unique_path("missing");
// path NO existe.
assert!(load_from_path(&path).is_none());
}
#[test]
fn load_from_unknown_name_returns_none() {
let path = unique_path("unknown");
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
std::fs::write(&path, "DefinitelyNotARealTheme").unwrap();
assert!(load_from_path(&path).is_none());
let _ = std::fs::remove_file(&path);
let _ = std::fs::remove_dir(path.parent().unwrap());
}
#[test]
fn persist_creates_parent_dir_if_missing() {
let path = unique_path("nested-create");
// Aseguramos que el parent NO existe antes.
let _ = std::fs::remove_dir_all(path.parent().unwrap());
persist_to_path(&Theme::sunset(), &path).unwrap();
assert!(path.exists());
let loaded = load_from_path(&path).unwrap();
assert_eq!(loaded.name, "Sunset");
let _ = std::fs::remove_file(&path);
let _ = std::fs::remove_dir(path.parent().unwrap());
}
#[test]
fn config_path_uses_xdg_config_home_when_set() {
// Snapshot del env, mutación local, restauración.
let prev = std::env::var("XDG_CONFIG_HOME").ok();
// SAFETY: tests del crate single-thread por default; este
// env mutation no impacta otros tests del mismo proceso.
unsafe {
std::env::set_var("XDG_CONFIG_HOME", "/custom/xdg");
}
let p = config_path().unwrap();
unsafe {
match prev {
Some(v) => std::env::set_var("XDG_CONFIG_HOME", v),
None => std::env::remove_var("XDG_CONFIG_HOME"),
}
}
assert_eq!(p, PathBuf::from("/custom/xdg/nahual/theme"));
}
}
@@ -0,0 +1,14 @@
[package]
name = "nahual-widget-app-header"
version.workspace = true
edition.workspace = true
license.workspace = true
description = "Yahweh — widget app-header: tira superior con label flex_grow + theme switcher a la derecha + bg panel + border bottom. Patrón compartido por las apps explorer del repo."
[dependencies]
gpui = { workspace = true }
nahual-theme = { path = "../../libs/theme" }
nahual-widget-theme-switcher = { path = "../theme-switcher" }
[dev-dependencies]
gpui = { workspace = true, features = ["test-support"] }
@@ -0,0 +1,96 @@
//! `nahual-widget-app-header` — tira superior estándar de las apps
//! del repo.
//!
//! Compone:
//! - Label dinámico a la izquierda (flex_grow).
//! - [`theme_switcher`] a la derecha.
//! - bg = `theme.bg_panel`, text = `theme.fg_text`,
//! border-bottom = `theme.border`.
//! - Padding 16/12, text_size 14.
//!
//! Patrón emergente: `nakui-explorer`, `akasha-explorer`,
//! `minga-explorer`, `brahman-broker-explorer` declaran headers
//! idénticos sólo cambiando el label. Ahora es 1 línea.
//!
//! # Ejemplo
//!
//! ```ignore
//! use nahual_widget_app_header::app_header;
//!
//! let header = app_header(cx, format!("Log: {} · {} entries", path, n));
//! div().child(header).child(body)
//! ```
#![forbid(unsafe_code)]
use gpui::{div, prelude::*, px, App, IntoElement, SharedString};
use nahual_theme::Theme;
use nahual_widget_theme_switcher::theme_switcher;
/// Construye el header standard. Lee `Theme::global(cx)` para los
/// colors; falla si no hay theme instalado (panic propagado de
/// `Theme::global`).
///
/// `label` es texto plano. Para labels más ricos (ej. icon + text,
/// múltiples spans), usar [`app_header_with`] que acepta
/// cualquier child element.
pub fn app_header(cx: &mut App, label: impl Into<SharedString>) -> impl IntoElement {
let label: SharedString = label.into();
app_header_with(cx, div().child(label))
}
/// Variante de [`app_header`] que acepta cualquier `IntoElement`
/// como contenido del lado izquierdo. El widget envuelve el child
/// en un `div().flex_grow()` para que el switcher quede pegado a
/// la derecha.
pub fn app_header_with(cx: &mut App, label_child: impl IntoElement) -> impl IntoElement {
let theme = Theme::global(cx).clone();
div()
.flex()
.flex_row()
.items_center()
.px(px(16.))
.py(px(12.))
.bg(theme.bg_panel.clone())
.border_b_1()
.border_color(theme.border)
.text_color(theme.fg_text)
.text_size(px(14.))
.child(div().flex_grow().child(label_child))
.child(theme_switcher(cx))
}
#[cfg(test)]
mod tests {
use super::*;
use gpui::TestAppContext;
#[gpui::test]
fn app_header_constructs_with_string_label(cx: &mut TestAppContext) {
cx.update(|cx| {
Theme::install_default(cx);
let _h = app_header(cx, "Test header");
});
}
#[gpui::test]
fn app_header_with_accepts_arbitrary_child(cx: &mut TestAppContext) {
cx.update(|cx| {
Theme::install_default(cx);
let _h = app_header_with(
cx,
div().child(SharedString::from("Custom child")),
);
});
}
#[gpui::test]
fn app_header_label_accepts_owned_or_borrowed(cx: &mut TestAppContext) {
cx.update(|cx| {
Theme::install_default(cx);
let _ = app_header(cx, "literal");
let _ = app_header(cx, "owned".to_string());
let _ = app_header(cx, format!("formatted {}", 42));
});
}
}
@@ -0,0 +1,10 @@
[package]
name = "nahual-widget-banner"
version.workspace = true
edition.workspace = true
license.workspace = true
description = "Yahweh — widget banner: tira horizontal de status (info/success/warning/error). Reusable cross-app para toasts, errores, mensajes informativos."
[dependencies]
gpui = { workspace = true }
nahual-theme = { path = "../../libs/theme" }
@@ -0,0 +1,207 @@
//! `nahual-widget-banner` — tiras horizontales de status.
//!
//! Cuatro variants con paleta consistente entre apps:
//!
//! - [`Banner::Info`] — azul tenue, mensajes neutros.
//! - [`Banner::Success`] — verde, confirmaciones de op exitosa
//! (toasts típicos).
//! - [`Banner::Warning`] — amber, llamadas de atención (modales
//! de confirmación, condiciones de "por las dudas").
//! - [`Banner::Error`] — rojo, errores fatales o de carga.
//!
//! Diseño: una `Div` GPUI con paddings + colors hardcoded por
//! variant. El caller añade niños via el builder de div (`.child(...)`,
//! `.flex()`, etc.) para customizar más allá del default.
//!
//! # Ejemplo
//!
//! ```ignore
//! use nahual_widget_banner::{banner, Banner};
//!
//! // Toast simple (success):
//! let toast = banner(Banner::Success, "guardado");
//!
//! // Banner de error con extra child:
//! let err = banner(Banner::Error, "no pude leer log").child(
//! div().text_size(px(10.)).child("(timeout 3s)")
//! );
//! ```
#![forbid(unsafe_code)]
use gpui::{div, hsla, prelude::*, px, App, Background, Div, Hsla, Rgba, SharedString};
use nahual_theme::Theme;
/// Severidad / tono del banner. Determina los colores del fondo,
/// texto y border (si aplica). El caller no debería mezclar
/// kinds en un mismo banner — usar la composición de divs si
/// hace falta una vista híbrida.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Banner {
Info,
Success,
Warning,
Error,
}
impl Banner {
/// Color de fondo del banner (sin alpha).
pub fn bg(self) -> Rgba {
match self {
Banner::Info => gpui::rgb(0x1d2a3a),
Banner::Success => gpui::rgb(0x2d3a2a),
Banner::Warning => gpui::rgb(0x4a3a1a),
Banner::Error => gpui::rgb(0x4a2020),
}
}
/// Color del texto principal del banner.
pub fn fg(self) -> Rgba {
match self {
Banner::Info => gpui::rgb(0xc0d0e0),
Banner::Success => gpui::rgb(0xc0e0a0),
Banner::Warning => gpui::rgb(0xf0e0a0),
Banner::Error => gpui::rgb(0xffd0d0),
}
}
}
/// Construye un banner con el `kind` indicado y `message` como
/// texto principal. Devuelve un [`Div`] al que el caller puede
/// agregar children, `id`, handlers, etc.
///
/// Padding y text_size son los defaults estándar del repo
/// (`px(12./6.)` en cada axis, `px(11.)` para el texto). Para un
/// banner más grande/chico, llamar `.text_size(...)` / `.px(...)`
/// sobre el resultado.
pub fn banner(kind: Banner, message: impl Into<SharedString>) -> Div {
div()
.px(px(12.))
.py(px(6.))
.bg(kind.bg())
.text_color(kind.fg())
.text_size(px(11.))
.child(message.into())
}
/// Variante themed de [`banner`]: deriva colores siguiendo el
/// `Theme::global(cx).is_dark` (lightness flip dark ↔ light) +
/// hue fijo por kind (verde para Success, amber para Warning,
/// rojo para Error). Info usa `theme.bg_panel_alt` + `theme.accent`
/// para integrarse al chrome del app.
///
/// Beneficio sobre [`banner`]: cuando el usuario cambia de theme
/// claro a oscuro, los banners ajustan contraste sin esfuerzo.
///
/// Si la app no instaló un `Theme`, panicea (`Theme::global` lo
/// requiere). Para apps sin theme, usar [`banner`] directo.
pub fn banner_themed(cx: &App, kind: Banner, message: impl Into<SharedString>) -> Div {
let theme = Theme::global(cx);
let (bg, fg) = themed_colors(kind, theme);
div()
.px(px(12.))
.py(px(6.))
.bg(bg)
.text_color(fg)
.text_size(px(11.))
.child(message.into())
}
/// Deriva el par `(bg, fg)` para un kind dado contra el theme.
/// Public para tests + para que los consumers puedan computar el
/// par sin construir el div (ej. para custom layouts).
pub fn themed_colors(kind: Banner, theme: &Theme) -> (Background, Hsla) {
match kind {
Banner::Info => (theme.bg_panel_alt.clone(), theme.accent),
Banner::Success => derive_pair(120.0 / 360.0, theme.is_dark),
Banner::Warning => derive_pair(40.0 / 360.0, theme.is_dark),
Banner::Error => derive_pair(0.0 / 360.0, theme.is_dark),
}
}
/// Computa `(bg, fg)` para un hue fijo respetando dark/light mode:
/// dark → bg low-lightness, fg high-lightness; light → invertido.
fn derive_pair(hue: f32, is_dark: bool) -> (Background, Hsla) {
let (bg_l, fg_l) = if is_dark { (0.18, 0.85) } else { (0.92, 0.20) };
let bg_hsla = hsla(hue, 0.40, bg_l, 1.0);
let fg_hsla = hsla(hue, 0.40, fg_l, 1.0);
(bg_hsla.into(), fg_hsla)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn each_kind_has_distinct_bg_color() {
// Sanity: ningún kind comparte bg con otro. Si emerge una
// versión "low-contrast" de algún kind, abrir en otro
// variant en vez de re-usar el color.
let bgs = [
Banner::Info.bg(),
Banner::Success.bg(),
Banner::Warning.bg(),
Banner::Error.bg(),
];
let mut seen = std::collections::BTreeSet::new();
for b in &bgs {
assert!(
seen.insert((b.r * 1000.0) as u32 + (b.g * 1000.0) as u32 * 1000),
"bg colors collision"
);
}
}
#[test]
fn derive_pair_dark_uses_low_bg_and_high_fg() {
let (_bg, fg) = derive_pair(0.0, true);
// En dark mode, fg lightness es alta para contraste.
assert!(
fg.l > 0.7,
"fg lightness debería ser alta en dark, got {}",
fg.l
);
}
#[test]
fn derive_pair_light_uses_high_bg_and_low_fg() {
let (_bg, fg) = derive_pair(0.0, false);
// En light mode, fg lightness es baja para contraste.
assert!(
fg.l < 0.3,
"fg lightness debería ser baja en light, got {}",
fg.l
);
}
#[test]
fn derive_pair_distinguishes_kinds_by_hue() {
// Success/Warning/Error tienen hue distinto; bg lightness
// sigue al is_dark de igual forma cross-kind. Así verificar
// que cambiar el hue cambia bg.h (no la lightness).
let (_, fg_success) = derive_pair(120.0 / 360.0, true);
let (_, fg_warning) = derive_pair(40.0 / 360.0, true);
let (_, fg_error) = derive_pair(0.0, true);
assert!(
fg_success.h != fg_warning.h,
"success y warning deben diferir en hue"
);
assert!(fg_warning.h != fg_error.h);
}
#[test]
fn each_kind_has_distinct_fg_color() {
let fgs = [
Banner::Info.fg(),
Banner::Success.fg(),
Banner::Warning.fg(),
Banner::Error.fg(),
];
let mut seen = std::collections::BTreeSet::new();
for f in &fgs {
assert!(
seen.insert((f.r * 1000.0) as u32 + (f.g * 1000.0) as u32 * 1000)
);
}
}
}
@@ -0,0 +1,10 @@
[package]
name = "nahual-widget-card"
version.workspace = true
edition.workspace = true
license.workspace = true
description = "Yahweh — widget card: container con padding + rounded + flex_col consistentes para timeline entries, list rows expandidas, info cards. Agnóstico del color (caller decide bg/border)."
[dependencies]
gpui = { workspace = true }
nahual-theme = { path = "../../libs/theme" }
@@ -0,0 +1,80 @@
//! `nahual-widget-card` — container card-shape para entries de
//! timeline, info cards y similares.
//!
//! Aporta la **forma**: padding consistente (12/8), `rounded(4)`,
//! `flex_col` con `gap(2)`. NO aporta colores — el caller decide
//! `bg`, `border_color`, etc. via builder calls. Esto permite que
//! distintos consumers (timeline con accent por kind, info card
//! con bg uniforme) compartan la misma proporción visual sin
//! acoplarse a una paleta fija.
//!
//! # Ejemplo
//!
//! ```ignore
//! use nahual_widget_card::card;
//! use gpui::{rgb, prelude::*, px};
//!
//! // Card con accent border-l (típico timeline entry):
//! let entry = card()
//! .bg(rgb(0x1d2128))
//! .border_l_4()
//! .border_color(rgb(0x88c0d0))
//! .child(div().child("header"))
//! .child(div().child("body"));
//!
//! // Card sin border (info card uniforme):
//! let info = card()
//! .bg(rgb(0x1d2128))
//! .child("contenido");
//! ```
#![forbid(unsafe_code)]
use gpui::{div, prelude::*, px, App, Div};
use nahual_theme::Theme;
/// Container card-shape: `flex_col` con padding `12/8`, `rounded(4)`,
/// `gap(2)` interno entre children y `mb(4)` para separación
/// vertical de cards apiladas.
///
/// Sin colores aplicados — el caller agrega `.bg(...)`,
/// `.border_color(...)`, `.border_l_4()`, etc. según necesite.
///
/// El return es un `Div` GPUI — todas las builder methods de div
/// están disponibles (children, hover, on_click, ids, etc.).
pub fn card() -> Div {
div()
.flex()
.flex_col()
.px(px(12.))
.py(px(8.))
.mb(px(4.))
.rounded(px(4.))
.gap(px(2.))
}
/// Variante themed: igual que [`card`] pero pre-aplica `bg(panel)`
/// del [`Theme`] global. El caller no necesita conocer la paleta —
/// el bg sigue al theme actual cuando éste cambia.
///
/// Si la app no instaló un Theme, esta función panicea (gpui's
/// `cx.global::<Theme>()` requiere el global instalado). Para apps
/// sin theme, usar [`card`] directo.
pub fn card_themed(cx: &App) -> Div {
let theme = Theme::global(cx);
card().bg(theme.bg_panel.clone())
}
#[cfg(test)]
mod tests {
use super::*;
/// Sanity smoke: el constructor devuelve un Div sin panic. No
/// podemos asertar las property de styling sin renderear (que
/// requiere TestAppContext + window). Si la signature cambia,
/// el código no compila — eso es la real garantía.
#[test]
fn card_returns_div_without_panic() {
let _d = card();
}
}
@@ -0,0 +1,10 @@
[package]
name = "nahual-widget-container-core"
version = { workspace = true }
edition = { workspace = true }
license = { workspace = true }
description = "Tipos compartidos para contenedores (ChildSlot, etc.). Imported por Splitter, Tabs, Tiled y la Shell."
[dependencies]
gpui = { workspace = true }
nahual-core = { workspace = true }
@@ -0,0 +1,38 @@
//! `nahual_widget_container_core` — tipos compartidos por todos los
//! contenedores (Splitter, Tabs, Tiled, futuros).
//!
//! La pieza más relevante es [`ChildSlot`]: el "paquete" con que la Shell
//! le entrega a un contenedor un hijo ya instanciado. La identidad
//! estable (`id: NodeId`) es lo que permite **swappear el kind del
//! contenedor sin perder los hijos**: cuando el JSON cambia
//! `kind: "Split"` por `kind: "Tabs"`, el LayoutHost descarta el viejo
//! contenedor pero pasa los mismos `ChildSlot` (con los mismos AnyView ya
//! con estado) al contenedor nuevo. Esa preservación es la promesa
//! arquitectónica de la app.
//!
//! `flex` y `label` son metadatos opcionales que cada contenedor
//! interpreta a su gusto:
//! - Splitter: usa `flex` para repartir; ignora `label`.
//! - Tabs: usa `label` para el título de la pestaña; ignora `flex`.
//! - Tiled: usa ambos opcionalmente (peso de tile, label hover).
use gpui::AnyView;
use nahual_core::NodeId;
/// Slot de un hijo entregado a un contenedor. La Shell construye el
/// `Vec<ChildSlot>` haciendo DFS sobre el `LayerConfig` del JSON.
#[derive(Clone)]
pub struct ChildSlot {
/// Identidad estable (proviene del campo `id` del JSON, o se
/// sintetiza desde el path estructural).
pub id: NodeId,
/// Peso flex relativo entre hermanos. Útil para Splitter / Tiled;
/// los contenedores que no lo usan lo ignoran.
pub flex: f32,
/// Texto opcional para decoración (título de tab, label de tile, etc).
/// Si `None`, los contenedores que lo necesiten caen al `id` como
/// fallback razonable.
pub label: Option<String>,
/// El widget instanciado, listo para colgar del árbol de render.
pub view: AnyView,
}
@@ -0,0 +1,22 @@
[package]
name = "nahual-widget-meta-form"
version.workspace = true
edition.workspace = true
license.workspace = true
description = "Yahweh — widget metainterfaz: lista, formulario, modal de delete y selector EntityRef que renderean cualquier `yahweh-meta-schema::Module` contra cualquier `yahweh_meta_runtime::MetaBackend`. App-agnostic."
[dependencies]
gpui = { workspace = true }
serde_json = { workspace = true }
uuid = { workspace = true, features = ["serde"] }
nahual-meta-runtime = { path = "../../libs/meta-runtime" }
nahual-meta-schema = { path = "../../libs/meta-schema" }
nahual-theme = { path = "../../libs/theme" }
nahual-widget-banner = { path = "../banner" }
nahual-widget-theme-switcher = { path = "../theme-switcher" }
nahual-widget-text-input = { path = "../text_input" }
[dev-dependencies]
# Activar TestAppContext + helpers para tests del widget que
# necesiten un cx GPUI sintético (sin abrir window real).
gpui = { workspace = true, features = ["test-support"] }
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,238 @@
//! Tests E2E del widget [`MetaApp`] usando
//! [`nahual_meta_runtime::testing::MockBackend`] +
//! `gpui::TestAppContext`.
//!
//! Cubren el flujo "construir el widget con un backend mock,
//! invocar handlers reales (`apply_action`, `select_view`, etc.),
//! verificar el state resultante" — sin abrir ventana ni
//! requerir display server.
//!
//! Limitación conocida: render() necesita window context que
//! `TestAppContext` no provee fácilmente. Estos tests se enfocan
//! en state machine + backend wiring, no en pixels.
use std::collections::BTreeMap;
use gpui::TestAppContext;
use serde_json::json;
use nahual_meta_runtime::testing::MockBackend;
use nahual_meta_schema::{
Action, Column, EntitySpec, FieldKind, FieldSpec, FormView, ListView, MenuItem, Module, View,
};
use nahual_theme::Theme;
use nahual_widget_meta_form::MetaApp;
/// Helper: módulo demo simple con una entity Customer + view list.
fn customers_module() -> Module {
let mut views = std::collections::BTreeMap::new();
views.insert(
"list".to_string(),
View::List(ListView {
title: "Customers".into(),
entity: "Customer".into(),
columns: vec![Column {
field: "name".into(),
label: "Nombre".into(),
weight: 1.0,
}],
actions: vec![],
search_in: vec![],
}),
);
views.insert(
"form".to_string(),
View::Form(FormView {
title: "Nuevo customer".into(),
entity: "Customer".into(),
fields: vec![FieldSpec {
name: "name".into(),
label: "Nombre".into(),
kind: FieldKind::Text,
default: None,
required: true,
help: None,
ref_entity: None,
}],
on_submit: Action::SeedEntity {
entity: "Customer".into(),
next_view: Some("list".into()),
},
}),
);
Module {
id: "customers".into(),
label: "Clientes".into(),
description: None,
entities: vec![EntitySpec {
name: "Customer".into(),
label: "Customer".into(),
fields: vec![],
}],
nakui_module_dir: None,
menu: vec![
MenuItem {
label: "Listar".into(),
view: "list".into(),
icon: None,
},
MenuItem {
label: "Nuevo".into(),
view: "form".into(),
icon: None,
},
],
views,
}
}
/// Construir un MetaApp con MockBackend pre-poblado y verificar
/// state inicial: modules cargados, active view = primera del menú,
/// toast inicial trasladado.
#[gpui::test]
fn meta_app_constructs_with_mock_backend_and_initial_state(cx: &mut TestAppContext) {
cx.update(|cx| Theme::install_default(cx));
let id = uuid::Uuid::new_v4();
let backend = MockBackend::with_records([(
"Customer".into(),
id,
json!({"name": "Acme"}),
)]);
let modules = vec![customers_module()];
let entity = cx.add_window(|_w, cx| {
MetaApp::new(
modules,
backend,
Some("hola".into()),
None,
cx,
)
});
let _ = entity; // mantener viva la window para el reactor.
}
/// Apply Action::OpenView debería cambiar la active view del widget.
/// Validamos que despues de un open_view a "form", el state interno
/// refleja el cambio (via la naturaleza de side-effects del handler;
/// no podemos leer fields privados, pero podemos correr de nuevo y
/// observar que el flow no panicea).
#[gpui::test]
fn open_view_action_does_not_panic(cx: &mut TestAppContext) {
cx.update(|cx| Theme::install_default(cx));
let backend = MockBackend::new();
let modules = vec![customers_module()];
let window = cx.add_window(|_w, cx| {
MetaApp::new(modules, backend, None, None, cx)
});
// Update vía window: ejecutar apply_action.
window
.update(cx, |meta, _w, cx| {
meta.apply_action(
Action::OpenView {
view: "form".into(),
label: None,
},
cx,
);
})
.unwrap();
}
/// Sanity: el backend que pasa al widget puede ser inspeccionado
/// indirectamente. Pre-popular con records y verificar que un
/// `list_records` posterior los devuelve.
///
/// Hace doble propósito: (1) demuestra el patrón "backend
/// pre-poblado para fixtures" y (2) sirve como signal de regresión
/// si el widget hipotéticamente "consumiera" el backend (no debería).
#[gpui::test]
fn backend_state_visible_from_widget_perspective(cx: &mut TestAppContext) {
cx.update(|cx| Theme::install_default(cx));
let id = uuid::Uuid::new_v4();
let backend = MockBackend::with_records([(
"Customer".into(),
id,
json!({"name": "Acme"}),
)]);
let modules = vec![customers_module()];
let window = cx.add_window(|_w, cx| {
MetaApp::new(modules, backend, None, None, cx)
});
// Read directo del backend via list_records, vía la API
// que renders usan internamente.
window
.update(cx, |_meta, _w, _cx| {
// Aquí no exponemos el backend, pero el state del widget
// refleja lo que MockBackend tiene. Si list_records sobre
// un nuevo MockBackend igual al construido devuelve el
// mismo record, validamos el contrato de cómo el mock
// simula state.
let mock_check = MockBackend::with_records([(
"Customer".into(),
id,
json!({"name": "Acme"}),
)]);
use nahual_meta_runtime::MetaBackend;
let rows = mock_check.list_records("Customer");
assert_eq!(rows.len(), 1);
assert_eq!(rows[0].0, id);
})
.unwrap();
}
/// Smoke test: los tipos compilan juntos. `MetaApp<MockBackend>` es
/// instanciable. `MockBackend` es Send/Sync-compatible-enough
/// para vivir en una `Entity` de GPUI (el bound del trait es
/// `'static`; se cumple).
#[gpui::test]
fn morphism_handler_can_be_registered_and_called_via_widget(
cx: &mut TestAppContext,
) {
cx.update(|cx| Theme::install_default(cx));
let counter = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
let counter_clone = counter.clone();
let backend = MockBackend::new().with_morphism(
"noop",
move |_inputs: &BTreeMap<String, uuid::Uuid>, _params| {
counter_clone.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
Ok(0)
},
);
let modules = vec![customers_module()];
let window = cx.add_window(|_w, cx| {
MetaApp::new(modules, backend, None, None, cx)
});
// Invocar un Action::Morphism vía apply_action: como el módulo
// demo no declara morphism + no hay nakui_module_dir, esperamos
// que el handler del backend reporte error claro (módulo
// inválido) — pero el counter del mock NO se debería incrementar
// porque la rama de morphism falla antes de llamar al handler.
window
.update(cx, |meta, _w, cx| {
meta.apply_action(
Action::Morphism {
name: "noop".into(),
inputs: BTreeMap::new(),
params: vec![],
next_view: None,
},
cx,
);
})
.unwrap();
// El counter sigue 0 porque el morphism fue invocado contra el
// mock-registered "noop", que SÍ incrementa, pero apply_action
// intentó vía MetaApp.commit_morphism que llama backend.morphism.
// Validamos ya sea el incremento (call exitosa) o el state
// estable (call fallida).
let count = counter.load(std::sync::atomic::Ordering::SeqCst);
assert!(count <= 1, "counter no debería exceder 1: got {count}");
}
@@ -0,0 +1,12 @@
[package]
name = "nahual-widget-splitter"
version = { workspace = true }
edition = { workspace = true }
license = { workspace = true }
description = "SplitContainer — n hijos con flex weights y divisores arrastrables."
[dependencies]
gpui = { workspace = true }
nahual-core = { workspace = true }
nahual-theme = { workspace = true }
nahual-widget-container-core = { workspace = true }
@@ -0,0 +1,395 @@
//! `nahual_widget_splitter` — `SplitContainer` genérico.
//!
//! Aloja `n` hijos `AnyView` con flex weights individuales y un divisor
//! arrastrable entre cada par adyacente. Dirección horizontal o vertical
//! intercambiable. Emite [`SplitEvent::FlexChanged`] cuando un drag termina,
//! para que el host (LayoutHost / DemoApp) persista los flex.
//!
//! El SplitContainer NO conoce a sus hijos: los recibe vía
//! `set_children(Vec<ChildSlot>)`. Eso permite que el LayoutHost reuse las
//! mismas instancias cuando el JSON cambia el `kind` del contenedor (Split
//! → Tabs → Tiled) — los AnyView siguen vivos, solo cambia su contenedor.
//!
//! Drag: usamos el patrón canónico de gpui (ver `data_table.rs` ejemplo) —
//! cada divider tiene un `canvas(prepaint, paint)` que en su paint registra
//! handlers de `MouseDown / MouseMove / MouseUp` a nivel de window vía
//! `window.on_mouse_event`. Esto garantiza que el drag continúa aunque el
//! cursor salga del divider.
use std::cell::RefCell;
use std::rc::Rc;
use gpui::{
App, Bounds, Context, EventEmitter, IntoElement, Length, MouseButton, MouseDownEvent,
MouseMoveEvent, MouseUpEvent, Pixels, Point, Render, Window, canvas, div, prelude::*, px,
};
use nahual_core::{LayoutDirection, NodeId};
use nahual_theme::Theme;
pub use nahual_widget_container_core::ChildSlot;
#[derive(Clone, Debug)]
pub enum SplitEvent {
/// Un drag actualizó los flex weights. Se emite UNA vez por movimiento
/// (cada frame durante un drag), con los IDs y flex finales de los dos
/// hijos adyacentes al divisor.
FlexChanged {
left_id: NodeId,
right_id: NodeId,
left_flex: f32,
right_flex: f32,
},
/// El drag terminó (mouseup). Útil para persistir batched.
DragEnd,
}
// =====================================================================
// Widget
// =====================================================================
/// Estado interno del drag activo. `divider_index` apunta al espacio entre
/// `children[i]` y `children[i+1]`. Los snapshots `flex_*_initial` y
/// `start_pos_main` se capturan en MouseDown — durante MouseMove se
/// recalcula el flex desde el delta.
struct DragState {
divider_index: usize,
start_pos_main: Pixels,
flex_left_initial: f32,
flex_right_initial: f32,
/// Longitud total del SplitContainer en el eje principal al iniciar el
/// drag (capturada de `bounds`). Usada para convertir delta_px ↔
/// delta_flex preservando el sum total.
total_main_size: Pixels,
total_flex_initial: f32,
}
pub struct SplitContainer {
children: Vec<ChildSlot>,
direction: LayoutDirection,
drag: Option<DragState>,
/// Bounds del frame anterior. Capturados vía canvas absolute en cada
/// paint. Lo usamos al iniciar drag para resolver `total_main_size`.
bounds: Rc<RefCell<Option<Bounds<Pixels>>>>,
}
impl EventEmitter<SplitEvent> for SplitContainer {}
impl SplitContainer {
pub fn new(direction: LayoutDirection, cx: &mut Context<Self>) -> Self {
cx.observe_global::<Theme>(|_, cx| cx.notify()).detach();
Self {
children: Vec::new(),
direction,
drag: None,
bounds: Rc::new(RefCell::new(None)),
}
}
pub fn set_children(&mut self, children: Vec<ChildSlot>, cx: &mut Context<Self>) {
self.children = children;
cx.notify();
}
pub fn set_direction(&mut self, direction: LayoutDirection, cx: &mut Context<Self>) {
if self.direction != direction {
self.direction = direction;
cx.notify();
}
}
pub fn direction(&self) -> LayoutDirection {
self.direction
}
pub fn children(&self) -> &[ChildSlot] {
&self.children
}
// -------- Drag handlers --------
fn start_drag(&mut self, divider_index: usize, position: Point<Pixels>) {
if divider_index >= self.children.len().saturating_sub(1) {
return;
}
let bounds = match *self.bounds.borrow() {
Some(b) => b,
None => return,
};
let raw_main = main_axis(self.direction, bounds.size.width, bounds.size.height);
// Restamos el espacio que ocupan los divisores — son fixed-size en el
// eje principal, no participan del flex. El "espacio disponible
// para flex" es lo que importa para convertir delta_px → delta_flex.
let dividers_total = px(DIVIDER_HIT_ZONE) * (self.children.len().saturating_sub(1) as f32);
let total_main = raw_main - dividers_total;
if total_main <= px(0.0) {
return;
}
let total_flex: f32 = self.children.iter().map(|c| c.flex.max(0.0)).sum();
let total_flex = total_flex.max(0.001);
let start_main = main_axis_pt(self.direction, position);
self.drag = Some(DragState {
divider_index,
start_pos_main: start_main,
flex_left_initial: self.children[divider_index].flex,
flex_right_initial: self.children[divider_index + 1].flex,
total_main_size: total_main,
total_flex_initial: total_flex,
});
}
fn continue_drag(&mut self, position: Point<Pixels>, cx: &mut Context<Self>) {
let Some(drag) = &self.drag else { return };
let drag_idx = drag.divider_index;
if drag_idx + 1 >= self.children.len() {
return;
}
let cur_main = main_axis_pt(self.direction, position);
let delta_px = cur_main - drag.start_pos_main;
// delta_flex = delta_px / total_main_size * total_flex_initial.
let total_main_f = f32::from(drag.total_main_size).max(1.0);
let delta_flex = (f32::from(delta_px) / total_main_f) * drag.total_flex_initial;
const MIN_FLEX: f32 = 0.05;
let new_left = (drag.flex_left_initial + delta_flex).max(MIN_FLEX);
let new_right = (drag.flex_right_initial - delta_flex).max(MIN_FLEX);
// Solo aplicamos si NINGUNO se aplastó al mínimo y se "comió" el
// delta — eso significa que el drag llegó al borde de un hijo.
let fits = (drag.flex_left_initial + delta_flex) >= MIN_FLEX
&& (drag.flex_right_initial - delta_flex) >= MIN_FLEX;
if !fits {
// Recortamos: aplicamos los mínimos pero no propagamos delta más
// allá del límite. Resultado: el divisor "frena" en el borde.
}
self.children[drag_idx].flex = new_left;
self.children[drag_idx + 1].flex = new_right;
let left_id = self.children[drag_idx].id.clone();
let right_id = self.children[drag_idx + 1].id.clone();
cx.emit(SplitEvent::FlexChanged {
left_id,
right_id,
left_flex: new_left,
right_flex: new_right,
});
cx.notify();
}
fn end_drag(&mut self, cx: &mut Context<Self>) {
if self.drag.take().is_some() {
cx.emit(SplitEvent::DragEnd);
cx.notify();
}
}
}
// =====================================================================
// Helpers de eje
// =====================================================================
fn main_axis(dir: LayoutDirection, w: Pixels, h: Pixels) -> Pixels {
match dir {
LayoutDirection::Horizontal => w,
_ => h,
}
}
fn main_axis_pt(dir: LayoutDirection, p: Point<Pixels>) -> Pixels {
match dir {
LayoutDirection::Horizontal => p.x,
_ => p.y,
}
}
// =====================================================================
// Render
// =====================================================================
/// Espesor visible de la franja del divisor (la barrita coloreada).
const DIVIDER_VISUAL: f32 = 4.0;
/// Espesor total de la zona interactiva: cursor + handlers de mouse. Más
/// generosa que el visual para no pelearse con el usuario al apuntar a
/// una banda de 4px. El visual queda centrado dentro del hit zone.
const DIVIDER_HIT_ZONE: f32 = 12.0;
impl Render for SplitContainer {
fn render(&mut self, _w: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let theme = Theme::global(cx).clone();
let direction = self.direction;
let entity = cx.entity();
let bounds_holder = self.bounds.clone();
let total_flex: f32 = self
.children
.iter()
.map(|c| c.flex.max(0.0))
.sum::<f32>()
.max(0.001);
// Root flex container.
let mut root = div().size_full().relative();
root = match direction {
LayoutDirection::Horizontal => root.flex().flex_row(),
_ => root.flex().flex_col(),
};
// Canvas absolute para capturar bounds del SplitContainer en cada
// frame. No participa del flex (absolute), no captura clicks
// (canvas sin id es no-interactivo).
root = root.child({
let bounds_holder = bounds_holder.clone();
canvas(
move |bounds, _w, _cx| {
*bounds_holder.borrow_mut() = Some(bounds);
},
|_, _, _, _| {},
)
.absolute()
.size_full()
});
// Children + dividers entre cada par.
let n = self.children.len();
for (i, child) in self.children.iter().enumerate() {
let weight = (child.flex.max(0.0) / total_flex).max(0.001);
let mut item = div().relative();
// flex_grow fraccional — el helper `flex_grow()` solo setea 1.0,
// así que vamos directo al campo subyacente para repartir
// proporcionalmente según el `flex` de cada slot.
item.style().flex_grow = Some(weight);
item.style().flex_shrink = Some(1.0);
// CRUCIAL: flex-basis = 0 (no `auto`). El default `auto` toma
// el min-content de cada hijo como punto de partida; cuando un
// hijo tiene contenido grande (canvas con WHEEL_SIZE fijo, un
// panel con muchos controles en flex_wrap, etc.) la suma de
// bases excede el contenedor y flexbox abandona el reparto
// por flex-grow para usar shrink proporcional a la basis —
// resultado: el ratio 1:4 que pide el host se ignora y el
// hijo más liviano (p. ej. el tree) se aplasta a 0px. Con
// basis=0 todo el espacio es "free space" y el ratio se
// respeta sin importar el contenido.
item.style().flex_basis = Some(Length::Definite(px(0.0).into()));
// Floor de shrink: con basis=0 esto rara vez importa, pero lo
// dejamos por defensa contra contenidos que fuercen min-size
// intrínseco (uniform_list mide su primera row, etc.).
item.style().min_size.width = Some(Length::Definite(px(0.0).into()));
item.style().min_size.height = Some(Length::Definite(px(0.0).into()));
// Eje cruzado: full. Eje principal: lo decide flex.
let item = match direction {
LayoutDirection::Horizontal => item.h_full(),
_ => item.w_full(),
}
.overflow_hidden()
.child(child.view.clone());
root = root.child(item);
// Divisor entre i e i+1 (no después del último).
if i + 1 < n {
let divider_idx = i;
let entity_for_canvas = entity.clone();
let is_active = self.drag.as_ref().map(|d| d.divider_index) == Some(divider_idx);
let visual_bg = if is_active {
theme.accent_strong
} else {
theme.border_strong
};
// Visual: la franja fina coloreada que el usuario ve.
let visual = match direction {
LayoutDirection::Horizontal => div()
.w(px(DIVIDER_VISUAL))
.h_full()
.bg(visual_bg),
_ => div()
.w_full()
.h(px(DIVIDER_VISUAL))
.bg(visual_bg),
};
// Hit zone: wrapper transparente más ancho que captura
// cursor y handlers de mouse. Centra el visual con flex.
// `relative` para que el canvas hijo (absolute) se ancle
// al wrapper y reporte sus bounds correctos.
let mut divider = div().relative().flex().items_center().justify_center();
divider = match direction {
LayoutDirection::Horizontal => divider
.w(px(DIVIDER_HIT_ZONE))
.h_full()
.cursor_ew_resize(),
_ => divider
.w_full()
.h(px(DIVIDER_HIT_ZONE))
.cursor_ns_resize(),
};
divider = divider.child(visual);
// Canvas con handlers de drag a nivel de window — su
// bounds = bounds del wrapper (hit zone completo), así
// que el `canvas_bounds.contains` acepta clicks en todo
// el ancho del hit zone, no solo sobre el visual.
let divider = divider.child(
canvas(
|_, _, _| (),
move |canvas_bounds: Bounds<Pixels>, _, window, _| {
// MouseDown sobre el divisor → start_drag.
window.on_mouse_event({
let entity = entity_for_canvas.clone();
move |ev: &MouseDownEvent, _, _w: &mut Window, cx: &mut App| {
if ev.button != MouseButton::Left {
return;
}
if !canvas_bounds.contains(&ev.position) {
return;
}
entity.update(cx, |this, _| {
this.start_drag(divider_idx, ev.position);
});
}
});
// MouseMove anywhere → continue_drag si hay drag.
window.on_mouse_event({
let entity = entity_for_canvas.clone();
move |ev: &MouseMoveEvent, _, _w: &mut Window, cx: &mut App| {
if !ev.dragging() {
return;
}
entity.update(cx, |this, cx| {
if this.drag.is_some() {
this.continue_drag(ev.position, cx);
}
});
}
});
// MouseUp anywhere → end_drag.
window.on_mouse_event({
let entity = entity_for_canvas.clone();
move |_: &MouseUpEvent, _, _w: &mut Window, cx: &mut App| {
entity.update(cx, |this, cx| this.end_drag(cx));
}
});
},
)
.absolute()
.size_full(),
);
root = root.child(divider);
}
}
root
}
}
@@ -0,0 +1,14 @@
[package]
name = "nahual-widget-stat-card"
version.workspace = true
edition.workspace = true
license.workspace = true
description = "Yahweh — widget stat card: tarjeta de dashboard con border-l accent + label + valor grande + descripción + listing opcional de items recientes. Patrón compartido entre minga-explorer y brahman-broker-explorer."
[dependencies]
gpui = { workspace = true }
nahual-widget-card = { path = "../card" }
[dev-dependencies]
gpui = { workspace = true, features = ["test-support"] }
nahual-theme = { path = "../../libs/theme" }
@@ -0,0 +1,180 @@
//! `nahual-widget-stat-card` — tarjeta de dashboard con accent.
//!
//! Compone:
//! - **`card_themed(cx)`** del [`nahual_widget_card`] como contenedor.
//! - **Border-l-4** con un color de accent que el caller decide
//! (verde = OK, rojo = error, etc.).
//! - **Label** chico arriba en el color del accent.
//! - **Value** grande (`px(28)`) en el color principal del text.
//! - **Description** chica en el color tenue.
//! - **Listing opcional** de items recientes con sub-header
//! `"recent (N de TOTAL):"`.
//!
//! El patrón emerge en dashboards estilo `minga-explorer` (counts
//! del repo + sample) y `brahman-broker-explorer` (estado del
//! probe). Cada consumer aporta sus propios accents semánticos.
//!
//! El widget no asume valor numérico — `value` es
//! `Into<SharedString>`, así que sirve igual para counts (`"3"`),
//! status text (`"UP / PROVIDER"`) o cualquier label corto.
//!
//! # Ejemplo
//!
//! ```ignore
//! use nahual_widget_stat_card::stat_card;
//! use gpui::{rgb, Hsla};
//!
//! let cell = stat_card(
//! cx,
//! "Nodos AST",
//! "247",
//! "fragments parseados del código",
//! rgb(0x88c0d0),
//! theme.fg_text,
//! theme.fg_muted,
//! &["abc123 fn_decl".into(), "def456 expr".into()],
//! );
//! ```
#![forbid(unsafe_code)]
use gpui::{div, prelude::*, px, App, IntoElement, SharedString};
use nahual_widget_card::card_themed;
/// Construye una stat card. Devuelve `impl IntoElement` para que el
/// caller pueda meterla directo como child de cualquier
/// `flex_col`/`gap` parent.
///
/// Args:
/// - `cx` — `&App` (acepta `&Context<T>` por deref). El widget lee
/// el theme global para el bg de la card.
/// - `label` — header chico, en el color del accent.
/// - `value` — texto principal, render grande (`px(28)`).
/// - `description` — texto chico tenue debajo del value.
/// - `accent` — color del border-l y del label.
/// - `text` — color principal (para el value).
/// - `text_dim` — color tenue (para description y sub-header de
/// recent).
/// - `recent_items` — slice de strings; si no vacío, se renderea
/// como sub-listing con header `"recent (N de TOTAL):"`. Cada
/// item ocupa una linea.
#[allow(clippy::too_many_arguments)]
pub fn stat_card(
cx: &App,
label: &str,
value: impl Into<SharedString>,
description: &str,
accent: gpui::Rgba,
text: gpui::Hsla,
text_dim: gpui::Hsla,
recent_items: &[String],
) -> impl IntoElement {
let value: SharedString = value.into();
let total_for_header = recent_items.len();
let mut card = card_themed(cx)
.border_l_4()
.border_color(accent)
.child(
div()
.text_color(accent)
.text_size(px(11.))
.child(SharedString::from(label.to_string())),
)
.child(
div()
.text_color(text)
.text_size(px(28.))
.child(value),
)
.child(
div()
.text_color(text_dim)
.text_size(px(11.))
.child(SharedString::from(description.to_string())),
);
if !recent_items.is_empty() {
// Sub-header indicando cuántos items se muestran.
// El "TOTAL" es el len del slice porque el caller ya lo
// truncó — no tenemos acceso al total original. Si el
// caller quiere "5 de 247", debe formatear el label/value
// con el total.
card = card.child(
div()
.mt(px(6.))
.text_color(text_dim)
.text_size(px(10.))
.child(SharedString::from(format!("recent ({total_for_header}):"))),
);
for it in recent_items {
card = card.child(
div()
.text_color(text)
.text_size(px(11.))
.child(SharedString::from(it.clone())),
);
}
}
card
}
#[cfg(test)]
mod tests {
use super::*;
use gpui::TestAppContext;
use nahual_theme::Theme;
/// Smoke test: el constructor lee el theme global y devuelve un
/// IntoElement. Sin TestAppContext no podemos asertar render
/// pixels — esto valida wireup + type-check.
#[gpui::test]
fn stat_card_constructs_with_theme(cx: &mut TestAppContext) {
cx.update(|cx| {
Theme::install_default(cx);
let theme = Theme::global(cx);
let _el = stat_card(
cx,
"Test",
"42",
"una descripción",
gpui::rgb(0x88c0d0),
theme.fg_text,
theme.fg_muted,
&[],
);
});
}
#[gpui::test]
fn stat_card_with_recent_items_works(cx: &mut TestAppContext) {
cx.update(|cx| {
Theme::install_default(cx);
let theme = Theme::global(cx);
let _el = stat_card(
cx,
"Items",
"3",
"items recientes:",
gpui::rgb(0xa3be8c),
theme.fg_text,
theme.fg_muted,
&["a1b2c3 foo".into(), "d4e5f6 bar".into(), "789012 baz".into()],
);
});
}
#[gpui::test]
fn stat_card_value_accepts_string_or_number_repr(cx: &mut TestAppContext) {
// Type-check: value es Into<SharedString>. Tanto literal
// string como `format!()` deberían funcionar.
cx.update(|cx| {
Theme::install_default(cx);
let theme = Theme::global(cx);
let _ = stat_card(cx, "L", "literal", "d", gpui::rgb(0), theme.fg_text, theme.fg_muted, &[]);
let _ = stat_card(cx, "L", format!("{}", 42), "d", gpui::rgb(0), theme.fg_text, theme.fg_muted, &[]);
let _ = stat_card(cx, "L", "owned".to_string(), "d", gpui::rgb(0), theme.fg_text, theme.fg_muted, &[]);
});
}
}
@@ -0,0 +1,12 @@
[package]
name = "nahual-widget-tabs"
version = { workspace = true }
edition = { workspace = true }
license = { workspace = true }
description = "TabContainer — n hijos, uno visible, header con tabs clickeables."
[dependencies]
gpui = { workspace = true }
nahual-core = { workspace = true }
nahual-theme = { workspace = true }
nahual-widget-container-core = { workspace = true }
@@ -0,0 +1,192 @@
//! `nahual_widget_tabs` — `TabContainer`.
//!
//! `n` hijos `AnyView`, **uno visible** por vez (la pestaña activa). Header
//! horizontal con un botón por hijo; click cambia la pestaña activa. La
//! identidad del hijo activo se preserva por `NodeId`, así que swappear de
//! Split → Tabs y volver no resetea cuál está abierto.
//!
//! API alineada con `SplitContainer` (mismo `set_children`) para que el
//! LayoutHost los use intercambiablemente.
use gpui::{
ClickEvent, Context, EventEmitter, IntoElement, Render, SharedString, Window, div, prelude::*,
px,
};
use nahual_core::NodeId;
use nahual_theme::Theme;
use nahual_widget_container_core::ChildSlot;
#[derive(Clone, Debug)]
#[allow(dead_code)]
pub enum TabsEvent {
/// Una pestaña distinta quedó activa (por click o `set_active`).
TabActivated { id: NodeId, index: usize },
}
pub struct TabContainer {
children: Vec<ChildSlot>,
/// Id del hijo activo. Lo guardamos por id (no por índice) para que
/// reorders/inserts no rompan la selección.
active_id: Option<NodeId>,
}
impl EventEmitter<TabsEvent> for TabContainer {}
impl TabContainer {
pub fn new(cx: &mut Context<Self>) -> Self {
cx.observe_global::<Theme>(|_, cx| cx.notify()).detach();
Self {
children: Vec::new(),
active_id: None,
}
}
pub fn set_children(&mut self, children: Vec<ChildSlot>, cx: &mut Context<Self>) {
// Si el id activo previo sigue presente, preservarlo. Si no, caer
// al primero (o None si vacío).
let still_present = self
.active_id
.as_ref()
.map(|id| children.iter().any(|c| &c.id == id))
.unwrap_or(false);
if !still_present {
self.active_id = children.first().map(|c| c.id.clone());
}
self.children = children;
cx.notify();
}
pub fn set_active(&mut self, id: NodeId, cx: &mut Context<Self>) {
if self.children.iter().any(|c| c.id == id) && self.active_id.as_ref() != Some(&id) {
let index = self.children.iter().position(|c| c.id == id).unwrap_or(0);
self.active_id = Some(id.clone());
cx.emit(TabsEvent::TabActivated { id, index });
cx.notify();
}
}
pub fn active_id(&self) -> Option<&NodeId> {
self.active_id.as_ref()
}
fn active_index(&self) -> Option<usize> {
let id = self.active_id.as_ref()?;
self.children.iter().position(|c| &c.id == id)
}
fn on_tab_click(
&mut self,
id: NodeId,
_click: &ClickEvent,
_w: &mut Window,
cx: &mut Context<Self>,
) {
self.set_active(id, cx);
}
}
const TAB_HEADER_HEIGHT: f32 = 30.0;
impl Render for TabContainer {
fn render(&mut self, _w: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let theme = Theme::global(cx).clone();
let active_idx = self.active_index();
// Header — una "pestaña" por hijo. Cada tab usa una stripe inferior
// (un div hijo de 2px de alto) como indicador de "activa", porque
// gpui no expone `border_b_color` por separado del border global.
let mut header = div()
.h(px(TAB_HEADER_HEIGHT))
.w_full()
.border_b_1()
.border_color(theme.border)
.bg(theme.bg_panel.clone())
.flex()
.flex_row();
for (i, child) in self.children.iter().enumerate() {
let is_active = active_idx == Some(i);
let label_text = child
.label
.clone()
.unwrap_or_else(|| child.id.as_str().to_string());
let id_for_click = child.id.clone();
let tab_id: SharedString =
SharedString::from(format!("tab-{}", child.id));
let bg = if is_active {
theme.bg_panel_alt.clone()
} else {
theme.bg_panel.clone()
};
let fg = if is_active {
theme.fg_text
} else {
theme.fg_muted
};
let stripe_color = if is_active {
theme.accent_strong
} else {
gpui::hsla(0.0, 0.0, 0.0, 0.0)
};
header = header.child(
div()
.id(tab_id)
.h_full()
.border_r_1()
.border_color(theme.border)
.bg(bg)
.text_color(fg)
.text_size(px(12.0))
.hover(|s| s.opacity(0.85))
.flex()
.flex_col()
.child(
// Etiqueta + padding centrado.
div()
.flex_grow()
.px(px(14.0))
.flex()
.items_center()
.child(SharedString::from(label_text)),
)
.child(
// Stripe inferior de 2px — indicador de activa.
div().h(px(2.0)).w_full().bg(stripe_color),
)
.on_click(cx.listener(move |this, click, w, cx| {
this.on_tab_click(id_for_click.clone(), click, w, cx);
})),
);
}
// Cuerpo — solo el child activo. Si no hay ninguno (children
// vacío), pintamos un mensaje neutro.
let body = match active_idx.and_then(|i| self.children.get(i)) {
Some(child) => div()
.flex_grow()
.min_h(px(0.0))
.bg(theme.bg_panel_alt.clone())
.child(child.view.clone())
.into_any_element(),
None => div()
.flex_grow()
.flex()
.items_center()
.justify_center()
.text_color(theme.fg_muted)
.text_size(px(11.0))
.child("(sin hijos)")
.into_any_element(),
};
div()
.size_full()
.flex()
.flex_col()
.child(header)
.child(body)
}
}
@@ -0,0 +1,10 @@
[package]
name = "nahual-widget-text-input"
version = { workspace = true }
edition = { workspace = true }
license = { workspace = true }
description = "TextInput minimalista para diálogos (rename, prompts). Single-line, sin selección/clipboard."
[dependencies]
gpui = { workspace = true }
nahual-theme = { workspace = true }
@@ -0,0 +1,205 @@
//! `nahual_widget_text_input` — input de texto minimal.
//!
//! Diseñado para diálogos cortos (rename, prompts). NO es un editor — no
//! soporta:
//! - cursor positioning con flechas / mouse,
//! - selección con shift / arrastre,
//! - copy / cut / paste,
//! - IME / multilínea.
//!
//! Soporta lo justo:
//! - escribir caracteres (cualquier `key_char` printable los appendea al final),
//! - `Backspace` quita el último char,
//! - `Enter` emite [`TextInputEvent::Confirmed`] con el texto actual,
//! - `Escape` emite [`TextInputEvent::Cancelled`].
//!
//! Cuando montes el widget, llamá `request_focus(window)` para que reciba
//! teclas de inmediato. El padre se subscribe vía `cx.subscribe(&input,
//! …)` para recibir Confirmed/Cancelled.
//!
//! Cuando necesitemos algo serio (selección, posiciones, IME), portamos el
//! ejemplo `gpui::examples::input` o adoptamos `gpui-input` cuando exista.
use std::time::Duration;
use gpui::{
Context, EventEmitter, FocusHandle, Focusable, IntoElement, KeyDownEvent, Render,
SharedString, Task, Window, div, prelude::*, px,
};
use nahual_theme::Theme;
/// Período de toggle del caret. 500ms es el estándar de los inputs
/// del SO; ni rápido demasiado (distrae) ni lento (parece muerto).
const CARET_BLINK_INTERVAL: Duration = Duration::from_millis(500);
#[derive(Clone, Debug)]
pub enum TextInputEvent {
/// El usuario apretó Enter. El payload es el texto actual.
Confirmed(String),
/// El usuario apretó Escape. El padre suele cerrar el modal.
Cancelled,
}
pub struct TextInput {
text: String,
focus_handle: FocusHandle,
/// Placeholder visible cuando `text` está vacío.
placeholder: SharedString,
/// Toggle del caret: alterna cada [`CARET_BLINK_INTERVAL`]
/// entre `true` (visible) y `false` (oculto). El render lo
/// considera junto con focus para decidir si dibujar `|`.
caret_visible: bool,
/// Task del loop de blink. Se mantiene en self para que el
/// drop del widget cancele el loop (sino seguiría tickeando
/// y notificando contra un Entity ya muerto).
_blink_task: Task<()>,
}
impl EventEmitter<TextInputEvent> for TextInput {}
impl Focusable for TextInput {
fn focus_handle(&self, _: &gpui::App) -> FocusHandle {
self.focus_handle.clone()
}
}
impl TextInput {
pub fn new(initial: impl Into<String>, cx: &mut Context<Self>) -> Self {
cx.observe_global::<Theme>(|_, cx| cx.notify()).detach();
// Loop de blink: alterna `caret_visible` y notifica para
// re-render. Vive en `_blink_task` (drop = cancel).
let blink_task = cx.spawn(async move |this, cx| {
let timer = cx.background_executor().clone();
loop {
timer.timer(CARET_BLINK_INTERVAL).await;
let updated = this
.update(cx, |me, cx| {
me.caret_visible = !me.caret_visible;
cx.notify();
})
.is_ok();
if !updated {
// Entity drop → salimos del loop.
break;
}
}
});
Self {
text: initial.into(),
focus_handle: cx.focus_handle(),
placeholder: SharedString::from(""),
caret_visible: true,
_blink_task: blink_task,
}
}
/// Setea el placeholder mostrado cuando el campo está vacío.
#[allow(dead_code)]
pub fn with_placeholder(mut self, placeholder: impl Into<SharedString>) -> Self {
self.placeholder = placeholder.into();
self
}
pub fn text(&self) -> &str {
&self.text
}
/// Reemplaza el contenido completo (e.g. al abrir un modal pre-cargado).
pub fn set_text(&mut self, text: impl Into<String>, cx: &mut Context<Self>) {
self.text = text.into();
cx.notify();
}
/// Pide focus para que las próximas teclas vayan al input. Llamar
/// cuando montás el widget en un modal para que esté "activo".
pub fn request_focus(&self, window: &mut Window) {
window.focus(&self.focus_handle);
}
fn handle_key_down(
&mut self,
event: &KeyDownEvent,
_w: &mut Window,
cx: &mut Context<Self>,
) {
let key = event.keystroke.key.as_str();
match key {
"enter" => {
cx.emit(TextInputEvent::Confirmed(self.text.clone()));
return;
}
"escape" => {
cx.emit(TextInputEvent::Cancelled);
return;
}
"backspace" => {
self.text.pop();
cx.notify();
return;
}
_ => {}
}
// Char "imprimible": tomamos `key_char` (que respeta el layout +
// modificadores) si está presente. `key_char` es el que el sistema
// dice "esto es lo que el usuario realmente escribió".
if let Some(ch) = event.keystroke.key_char.as_deref() {
// Solo apendeamos si NO contiene control chars (newline,
// backspace, etc — que llegarían como key_char en algunas
// plataformas).
if !ch.chars().any(|c| c.is_control()) {
self.text.push_str(ch);
cx.notify();
}
}
}
}
impl Render for TextInput {
fn render(&mut self, w: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let theme = Theme::global(cx).clone();
let is_empty = self.text.is_empty();
// Border-color depende del focus: focused → accent (señal
// clara de "vas a tipear acá"); blur → border (silencioso).
// Sin esto era imposible saber qué input estaba activo en
// un form con varios fields.
let is_focused = self.focus_handle.is_focused(w);
let border_color = if is_focused {
theme.accent_strong
} else {
theme.border
};
// Caret visible cuando: (1) input tiene focus AND (2) el
// toggle del blink loop está en `true`. El loop alterna
// cada 500ms — feel familiar a los inputs del SO.
let show_caret = is_focused && self.caret_visible;
let display: SharedString = if is_empty {
self.placeholder.clone()
} else if show_caret {
SharedString::from(format!("{}|", self.text))
} else {
SharedString::from(self.text.clone())
};
let text_color = if is_empty {
theme.fg_disabled
} else {
theme.fg_text
};
div()
.id("nahual-text-input")
.track_focus(&self.focus_handle)
.key_context("YahwehTextInput")
.on_key_down(cx.listener(Self::handle_key_down))
.px(px(10.0))
.py(px(6.0))
.min_w(px(200.0))
.bg(theme.bg_panel.clone())
.border_1()
.border_color(border_color)
.rounded(px(4.0))
.text_size(px(13.0))
.text_color(text_color)
.child(display)
}
}
@@ -0,0 +1,14 @@
[package]
name = "nahual-widget-theme-switcher"
version.workspace = true
edition.workspace = true
license.workspace = true
description = "Yahweh — widget para ciclar entre presets de Theme en runtime. Botón que muestra el nombre del theme actual y al click avanza al siguiente preset."
[dependencies]
gpui = { workspace = true }
nahual-theme = { path = "../../libs/theme" }
[dev-dependencies]
# TestAppContext + #[gpui::test] para tests del switcher.
gpui = { workspace = true, features = ["test-support"] }
@@ -0,0 +1,91 @@
//! `nahual-widget-theme-switcher` — botón clickable para ciclar
//! entre los presets de `Theme`.
//!
//! El botón muestra el nombre del theme actual; al click avanza al
//! siguiente preset según [`Theme::next_after`] (rotación circular
//! sobre [`Theme::all`]).
//!
//! El cambio se aplica con `Theme::set(cx, ...)` que invalida el
//! global y dispara redraws en todos los widgets que observan el
//! theme via `cx.observe_global::<Theme>()`. Para widgets que NO
//! observan el theme (ej. los themed wrappers de banner/card en su
//! versión actual, que leen el theme dentro de `render`), basta con
//! que el render se vuelva a invocar — esto sucede automáticamente
//! tras `cx.set_global` que marca todos los views como dirty.
//!
//! # Uso
//!
//! ```ignore
//! use nahual_widget_theme_switcher::theme_switcher;
//!
//! // Adentro de Render::render:
//! let switcher = theme_switcher(cx);
//! header.child(switcher)
//! ```
#![forbid(unsafe_code)]
use gpui::{div, prelude::*, px, App, ClickEvent, IntoElement, SharedString, Window};
use nahual_theme::Theme;
/// Construye el switcher: una `Div` clickable con el nombre del
/// theme actual + flecha indicadora. Al click rota al siguiente
/// preset.
///
/// Estilo: padding consistente con el resto de los chrome controls
/// del repo (`px(8/4)`), `bg(theme.bg_panel_alt)`, `text_color(fg_text)`.
/// Sin border, hover sutil con `bg_row_hover`.
///
/// El handler del click usa `cx.update_global::<Theme>` para
/// reemplazar el theme global; los widgets que leen
/// `Theme::global` en su próximo render verán el nuevo.
pub fn theme_switcher(cx: &mut App) -> impl IntoElement {
let theme = Theme::global(cx).clone();
let label = format!("Tema: {}", theme.name);
div()
.id("nahual-theme-switcher")
.px(px(8.))
.py(px(4.))
.bg(theme.bg_panel_alt.clone())
.text_color(theme.fg_text)
.text_size(px(11.))
.rounded(px(3.))
.hover(move |d| d.bg(theme.bg_row_hover))
.child(SharedString::from(label))
.on_click(|_event: &ClickEvent, _window: &mut Window, cx: &mut App| {
let current_name = Theme::global(cx).name;
let next = Theme::next_after(current_name);
Theme::set(cx, next);
})
}
#[cfg(test)]
mod tests {
use super::*;
use gpui::TestAppContext;
#[gpui::test]
fn switcher_constructs_with_theme_installed(cx: &mut TestAppContext) {
cx.update(|cx| {
Theme::install_default(cx);
let _div = theme_switcher(cx);
// Smoke: si llegamos aquí sin panic, el constructor lee
// el global, deriva colors, y construye un Div.
});
}
#[gpui::test]
fn theme_set_changes_global(cx: &mut TestAppContext) {
cx.update(|cx| {
Theme::install_default(cx);
let initial_name = Theme::global(cx).name;
// Ciclo manual sin pasar por el handler del click.
let next = Theme::next_after(initial_name);
Theme::set(cx, next.clone());
let after = Theme::global(cx).name;
assert_eq!(after, next.name);
assert_ne!(after, initial_name, "el ciclo debe cambiar el name");
});
}
}
@@ -0,0 +1,12 @@
[package]
name = "nahual-widget-tiled"
version = { workspace = true }
edition = { workspace = true }
license = { workspace = true }
description = "TiledContainer — n hijos en grid auto cols×rows."
[dependencies]
gpui = { workspace = true }
nahual-core = { workspace = true }
nahual-theme = { workspace = true }
nahual-widget-container-core = { workspace = true }
@@ -0,0 +1,327 @@
//! `nahual_widget_tiled` — `TiledContainer`.
//!
//! Distribuye `n` hijos en una grilla auto-calculada: `cols = ⌈√n⌉`,
//! `rows = ⌈n/cols⌉`. Las celdas tienen el mismo peso.
//!
//! ## Drag-to-swap
//!
//! Cada tile tiene una franja superior de 18px (la "title bar") con cursor
//! de `move`: arrastrarla dispara un swap. Anatomía:
//!
//! 1. Mouse down sobre la title bar de tile A → record `dragging_idx = A`.
//! 2. Mouse move (window-level) actualiza `hover_idx` chequeando bounds
//! de cada tile capturados en cada paint.
//! 3. Mouse up → si `hover_idx != dragging_idx` y son válidos, emitimos
//! [`TiledEvent::Reordered { from, to }`] para que el LayoutHost lo
//! persista (swap_children en el LayoutModel).
//!
//! Mientras dura el drag, el tile origen pinta un overlay translúcido y el
//! tile destino se resalta con border `accent_strong`. Sin el LayoutHost
//! persistiendo, el reorder es solo emisión — el `set_children` que viene
//! después del rebuild aplica el orden nuevo.
//!
//! Filosofía: el TiledContainer NO mantiene un orden propio en `Vec`, ni
//! reordena `self.children` localmente. Toda mutación va vía el modelo
//! (single source of truth). Eso garantiza que persiste, sobrevive a
//! reload y se ve consistente con el JSON.
use std::cell::RefCell;
use std::rc::Rc;
use gpui::{
App, Bounds, Context, EventEmitter, IntoElement, Length, MouseButton, MouseDownEvent,
MouseMoveEvent, MouseUpEvent, Pixels, Point, Render, Window, canvas, div, prelude::*, px,
};
use nahual_core::NodeId;
use nahual_theme::Theme;
use nahual_widget_container_core::ChildSlot;
#[derive(Clone, Debug)]
#[allow(dead_code)]
pub enum TiledEvent {
/// Drag-and-drop terminó con un swap entre el tile en `from_index` y
/// el de `to_index`. Los IDs van por valor para que el suscriptor no
/// tenga que reconsultar el container.
Reordered {
from_index: usize,
from_id: NodeId,
to_index: usize,
to_id: NodeId,
},
}
#[derive(Clone, Debug)]
struct DragState {
from_index: usize,
/// Índice sobre el que el cursor está actualmente. `None` si está
/// fuera de cualquier tile.
hover_index: Option<usize>,
}
pub struct TiledContainer {
children: Vec<ChildSlot>,
drag: Option<DragState>,
/// Bounds de cada tile en el último frame, indexados por posición en
/// `children`. Capturados via canvas en cada tile para que el drag
/// pueda hit-testear sin reflexión sobre el árbol.
tile_bounds: Rc<RefCell<Vec<Option<Bounds<Pixels>>>>>,
}
impl EventEmitter<TiledEvent> for TiledContainer {}
impl TiledContainer {
pub fn new(cx: &mut Context<Self>) -> Self {
cx.observe_global::<Theme>(|_, cx| cx.notify()).detach();
Self {
children: Vec::new(),
drag: None,
tile_bounds: Rc::new(RefCell::new(Vec::new())),
}
}
pub fn set_children(&mut self, children: Vec<ChildSlot>, cx: &mut Context<Self>) {
// Resize el vector de bounds para que el index sea válido en cada
// paint; los bounds reales se llenan en el canvas.
let n = children.len();
self.tile_bounds.borrow_mut().resize(n, None);
self.children = children;
cx.notify();
}
pub fn children(&self) -> &[ChildSlot] {
&self.children
}
fn start_drag(&mut self, idx: usize, cx: &mut Context<Self>) {
if idx >= self.children.len() {
return;
}
self.drag = Some(DragState {
from_index: idx,
hover_index: None,
});
cx.notify();
}
fn update_hover(&mut self, position: Point<Pixels>, cx: &mut Context<Self>) {
let Some(drag) = &mut self.drag else { return };
// Hit-test contra los bounds capturados.
let bounds = self.tile_bounds.borrow();
let mut new_hover = None;
for (i, b) in bounds.iter().enumerate() {
if let Some(b) = b {
if b.contains(&position) {
new_hover = Some(i);
break;
}
}
}
if drag.hover_index != new_hover {
drag.hover_index = new_hover;
cx.notify();
}
}
fn end_drag(&mut self, cx: &mut Context<Self>) {
let Some(drag) = self.drag.take() else { return };
if let Some(to) = drag.hover_index {
if to != drag.from_index
&& to < self.children.len()
&& drag.from_index < self.children.len()
{
let from_id = self.children[drag.from_index].id.clone();
let to_id = self.children[to].id.clone();
cx.emit(TiledEvent::Reordered {
from_index: drag.from_index,
from_id,
to_index: to,
to_id,
});
}
}
cx.notify();
}
}
const TILE_GAP: f32 = 4.0;
const TITLE_BAR_HEIGHT: f32 = 20.0;
impl Render for TiledContainer {
fn render(&mut self, _w: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let theme = Theme::global(cx).clone();
let n = self.children.len();
if n == 0 {
return div()
.size_full()
.bg(theme.bg_panel.clone())
.flex()
.items_center()
.justify_center()
.text_size(px(11.0))
.text_color(theme.fg_muted)
.child("(tiled vacío)");
}
let cols = (n as f32).sqrt().ceil() as usize;
let cols = cols.max(1);
let rows = (n + cols - 1) / cols;
let drag = self.drag.clone();
let entity = cx.entity();
let bounds_holder = self.tile_bounds.clone();
let mut col_container = div()
.size_full()
.bg(theme.bg_app.clone())
.flex()
.flex_col()
.gap(px(TILE_GAP))
.p(px(TILE_GAP));
for r in 0..rows {
let mut row_div = div()
.w_full()
.flex()
.flex_row()
.flex_grow()
.gap(px(TILE_GAP));
row_div.style().min_size.height = Some(Length::Definite(px(0.0).into()));
for c in 0..cols {
let idx = r * cols + c;
let mut tile = div().h_full();
tile.style().flex_grow = Some(1.0);
tile.style().flex_shrink = Some(1.0);
tile.style().min_size.width = Some(Length::Definite(px(0.0).into()));
let is_dragging_src = drag.as_ref().map(|d| d.from_index) == Some(idx);
let is_drop_target = drag.as_ref().and_then(|d| d.hover_index) == Some(idx)
&& drag.as_ref().map(|d| d.from_index) != Some(idx);
let border_color = if is_drop_target {
theme.accent_strong
} else {
theme.border
};
let tile = tile
.bg(theme.bg_panel.clone())
.border_1()
.border_color(border_color)
.rounded(px(4.0))
.overflow_hidden();
let tile = if let Some(child) = self.children.get(idx) {
let child = child.clone();
let opacity = if is_dragging_src { 0.45 } else { 1.0 };
// Canvas que captura el bounds del tile entero (para
// hit-test del drop target).
let bounds_holder_inner = bounds_holder.clone();
let bounds_canvas = canvas(
move |bounds, _w, _cx| {
let mut b = bounds_holder_inner.borrow_mut();
if idx < b.len() {
b[idx] = Some(bounds);
}
},
|_, _, _, _| {},
)
.absolute()
.size_full();
// Title bar — drag handle. Canvas con window-level
// mouse handlers, mismo patrón que SplitContainer.
let entity_for_canvas = entity.clone();
let title_canvas = canvas(
|_, _, _| (),
move |canvas_bounds: Bounds<Pixels>, _, window, _| {
window.on_mouse_event({
let entity = entity_for_canvas.clone();
move |ev: &MouseDownEvent, _, _w: &mut Window, cx: &mut App| {
if ev.button != MouseButton::Left {
return;
}
if !canvas_bounds.contains(&ev.position) {
return;
}
entity.update(cx, |this, cx| this.start_drag(idx, cx));
}
});
window.on_mouse_event({
let entity = entity_for_canvas.clone();
move |ev: &MouseMoveEvent, _, _w: &mut Window, cx: &mut App| {
if !ev.dragging() {
return;
}
entity.update(cx, |this, cx| {
if this.drag.is_some() {
this.update_hover(ev.position, cx);
}
});
}
});
window.on_mouse_event({
let entity = entity_for_canvas.clone();
move |_: &MouseUpEvent, _, _w: &mut Window, cx: &mut App| {
entity.update(cx, |this, cx| this.end_drag(cx));
}
});
},
)
.size_full();
// El layout del tile: title bar arriba (con label +
// canvas drag), body abajo (con la AnyView del child).
let label_text = child
.label
.clone()
.unwrap_or_else(|| child.id.as_str().to_string());
tile.flex().flex_col().opacity(opacity).child(
div()
.h(px(TITLE_BAR_HEIGHT))
.w_full()
.px(px(8.0))
.bg(theme.bg_panel_alt.clone())
.border_b_1()
.border_color(theme.border)
.text_size(px(10.0))
.text_color(theme.fg_muted)
.cursor_move()
.relative()
.child(
// Label + drag canvas (canvas absolute
// sobre la franja entera).
div()
.flex()
.items_center()
.h_full()
.child(gpui::SharedString::from(label_text)),
)
.child(title_canvas),
)
.child(
// Body — overlay con bounds canvas + el AnyView.
div()
.flex_grow()
.min_h(px(0.0))
.relative()
.child(bounds_canvas)
.child(child.view.clone()),
)
.into_any_element()
} else {
tile.opacity(0.35).into_any_element()
};
row_div = row_div.child(tile);
}
col_container = col_container.child(row_div);
}
col_container
}
}
@@ -0,0 +1,10 @@
[package]
name = "nahual-widget-tree"
version = { workspace = true }
edition = { workspace = true }
license = { workspace = true }
description = "TreeView genérico — widget agnóstico de dominio sobre GPUI."
[dependencies]
gpui = { workspace = true }
nahual-theme = { workspace = true }
@@ -0,0 +1,415 @@
//! `nahual_widget_tree` — TreeView genérico, agnóstico del dominio.
//!
//! Anatomía: el host (FileExplorer, DatabaseExplorer, …) calcula una lista
//! plana `Vec<TreeRow>` por DFS y la empuja con `set_rows`. El TreeView solo
//! renderea, captura interacciones y emite [`TreeEvent`]. Todo lo de
//! dominio (qué carga al expandir un branch, qué hacer en doble click, etc)
//! lo decide el host suscribiéndose vía `cx.subscribe`.
//!
//! Esta es la pieza que reemplaza al `gioser_tree::Tree` de Makepad. La
//! diferencia clave es de plomería: en GPUI no hay un global action queue
//! ni Buttons que capten clicks indebidamente — cada `div` tiene su
//! `.on_click` propio y la propagación se detiene explícitamente. Lo que
//! peleamos en Makepad acá no existe.
use std::collections::HashMap;
use std::ops::Range;
use gpui::{
ClickEvent, Context, ElementId, Entity, EventEmitter, Hsla, IntoElement, MouseButton,
MouseDownEvent, Pixels, Point, Render, SharedString, Window, div, prelude::*, px,
uniform_list,
};
use nahual_theme::Theme;
// =====================================================================
// Modelo público
// =====================================================================
/// Identificador opaco de una fila. Wrapper sobre `String` — el host elige
/// la representación (path, primary key, GUID). El TreeView lo trata como
/// dato opaco y lo usa de key del HashMap interno.
#[derive(Clone, Debug, Hash, PartialEq, Eq)]
pub struct RowId(pub String);
impl RowId {
pub fn new(s: impl Into<String>) -> Self {
Self(s.into())
}
pub fn as_str(&self) -> &str {
&self.0
}
}
impl From<String> for RowId {
fn from(s: String) -> Self {
Self(s)
}
}
impl From<&str> for RowId {
fn from(s: &str) -> Self {
Self(s.to_string())
}
}
impl std::fmt::Display for RowId {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.0)
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
pub enum RowKind {
Branch,
#[default]
Leaf,
}
#[derive(Clone, Debug, Default)]
pub struct TreeRow {
pub id: RowId,
pub label: String,
pub depth: u32,
pub kind: RowKind,
/// Solo aplica a `Branch`. El TreeView NO muta este campo — el host lo
/// pasa derivado de su propio `expanded: HashSet`.
pub expanded: bool,
/// Icono opcional (emoji o glyph) que se renderea entre chevron y label.
pub icon: Option<String>,
}
impl Default for RowId {
fn default() -> Self {
Self(String::new())
}
}
/// Eventos que el TreeView emite hacia su parent (`cx.subscribe(&tree, …)`).
#[derive(Clone, Debug)]
pub enum TreeEvent {
/// Click primario sobre el cuerpo de la fila (NO el chevron). El
/// TreeView ya actualizó su `active_id` internamente — esto es
/// notificación.
RowClicked(RowId),
/// Doble click sobre el cuerpo. Para Branch se emite además el toggle.
RowDoubleClicked(RowId),
/// Click en chevron, o doble click sobre Branch.
ChevronToggled(RowId),
/// Right-click. `id == None` cuando fue área vacía debajo de la última
/// fila. La posición es absoluta para que el host posicione su menú.
ContextMenuRequested {
id: Option<RowId>,
position: Point<Pixels>,
},
/// Cambio del `active_id` interno (por click, set_active externo, etc).
/// Se emite incluso cuando el cambio fue inducido externamente.
ActiveChanged(Option<RowId>),
}
// =====================================================================
// Widget
// =====================================================================
pub struct TreeView {
rows: Vec<TreeRow>,
/// Mapa id → índice en `rows`. Se reconstruye en cada `set_rows`. Útil
/// para resolver `id → row` en O(1) cuando vienen acciones desde un row.
index: HashMap<RowId, usize>,
/// Fila activa (cursor row).
active_id: Option<RowId>,
/// Marker colors externos (cross-container highlighting).
selected: HashMap<RowId, Hsla>,
/// Id estable del elemento raíz para GPUI — lo necesita `uniform_list`
/// para mantener el scroll state entre frames.
list_id: SharedString,
}
impl EventEmitter<TreeEvent> for TreeView {}
impl TreeView {
/// Crea un TreeView vacío. El parámetro `id` es libre — se usa solo
/// para identificar el `uniform_list` interno (debe ser único por
/// instancia). Ej.: `"file-tree"`, `"db-tree"`.
pub fn new(id: impl Into<SharedString>, cx: &mut Context<Self>) -> Self {
// Observar el theme global — cuando cambia, redibujamos para que el
// hover/active/marker reflejen la paleta nueva sin esperar el próximo
// evento de input.
cx.observe_global::<Theme>(|_, cx| cx.notify()).detach();
Self {
rows: Vec::new(),
index: HashMap::new(),
active_id: None,
selected: HashMap::new(),
list_id: id.into(),
}
}
/// API pública: el host pushea las filas. Triggerea redraw.
pub fn set_rows(&mut self, rows: Vec<TreeRow>, cx: &mut Context<Self>) {
self.index = rows
.iter()
.enumerate()
.map(|(i, r)| (r.id.clone(), i))
.collect();
self.rows = rows;
cx.notify();
}
pub fn rows(&self) -> &[TreeRow] {
&self.rows
}
pub fn set_active(&mut self, id: Option<RowId>, cx: &mut Context<Self>) {
if self.active_id != id {
self.active_id = id.clone();
cx.emit(TreeEvent::ActiveChanged(id));
cx.notify();
}
}
pub fn active_id(&self) -> Option<&RowId> {
self.active_id.as_ref()
}
pub fn set_selected(&mut self, sel: HashMap<RowId, Hsla>, cx: &mut Context<Self>) {
self.selected = sel;
cx.notify();
}
pub fn add_selected(&mut self, id: RowId, color: Hsla, cx: &mut Context<Self>) {
self.selected.insert(id, color);
cx.notify();
}
pub fn remove_selected(&mut self, id: &RowId, cx: &mut Context<Self>) {
if self.selected.remove(id).is_some() {
cx.notify();
}
}
// ----- internos -----
fn handle_row_click(&mut self, id: RowId, click: &ClickEvent, cx: &mut Context<Self>) {
// Activar.
let new_active = Some(id.clone());
if self.active_id != new_active {
self.active_id = new_active.clone();
cx.emit(TreeEvent::ActiveChanged(new_active));
}
cx.emit(TreeEvent::RowClicked(id.clone()));
if click.click_count() >= 2 {
cx.emit(TreeEvent::RowDoubleClicked(id.clone()));
// Doble click sobre Branch: toggle implícito.
if let Some(row) = self.index.get(&id).and_then(|i| self.rows.get(*i)) {
if matches!(row.kind, RowKind::Branch) {
cx.emit(TreeEvent::ChevronToggled(id));
}
}
}
cx.notify();
}
fn handle_chevron_click(&mut self, id: RowId, _click: &ClickEvent, cx: &mut Context<Self>) {
cx.emit(TreeEvent::ChevronToggled(id));
}
fn handle_right_click(
&mut self,
id: Option<RowId>,
event: &MouseDownEvent,
cx: &mut Context<Self>,
) {
cx.emit(TreeEvent::ContextMenuRequested {
id,
position: event.position,
});
}
}
// =====================================================================
// Render
// =====================================================================
const ROW_HEIGHT: f32 = 22.0;
const INDENT_PX: f32 = 14.0;
const CHEVRON_PX: f32 = 14.0;
impl Render for TreeView {
fn render(&mut self, _w: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let theme = Theme::global(cx).clone();
let row_count = self.rows.len();
let entity = cx.entity();
// Snapshot inmutable para que el closure de uniform_list pueda
// accederlo sin tomar prestado `self`.
let rows = self.rows.clone();
let active_id = self.active_id.clone();
let selected = self.selected.clone();
let list_id: ElementId = self.list_id.clone().into();
div()
.id("nahual-tree-root")
.key_context("YahwehTree")
.size_full()
.bg(theme.bg_panel.clone())
.text_color(theme.fg_text)
// Right-click sobre área vacía (debajo de las rows) — sin id de
// row. La capa de rows captura su propio right-click y stoppea
// propagación, así que esto solo se dispara en el "fondo".
.on_mouse_down(
MouseButton::Right,
cx.listener({
move |this, e: &MouseDownEvent, _, cx| {
this.handle_right_click(None, e, cx);
}
}),
)
.child(
uniform_list(list_id, row_count, move |range: Range<usize>, _w, _cx| {
range
.filter_map(|i| rows.get(i).cloned())
.map(|row| {
render_row(
row,
&theme,
&active_id,
&selected,
entity.clone(),
)
})
.collect()
})
.size_full(),
)
}
}
// =====================================================================
// Render por fila — fuera del `impl Render` para mantener el tamaño
// manejable y aislar el closure de uniform_list.
// =====================================================================
fn render_row(
row: TreeRow,
theme: &Theme,
active_id: &Option<RowId>,
selected: &HashMap<RowId, Hsla>,
entity: Entity<TreeView>,
) -> impl IntoElement {
let id_for_chev = row.id.clone();
let id_for_body = row.id.clone();
let id_for_ctx = row.id.clone();
let is_active = active_id.as_ref() == Some(&row.id);
let marker = selected.get(&row.id).copied();
let chevron_glyph = match (row.kind, row.expanded) {
(RowKind::Branch, true) => "",
(RowKind::Branch, false) => "",
(RowKind::Leaf, _) => " ",
};
let icon = row.icon.clone().unwrap_or_default();
let label = row.label.clone();
let depth = row.depth as f32;
let is_branch = matches!(row.kind, RowKind::Branch);
// Background del row. Capas: marker (si hay) → active → hover (gestionado
// por gpui via .hover()).
let row_bg = if is_active {
Some(theme.bg_row_active)
} else {
marker
};
// Element id estable por fila — uniform_list es virtualizado, los ids
// tienen que ser únicos para que GPUI re-use el cache de hitboxes.
let element_id: ElementId = SharedString::from(format!("row::{}", row.id)).into();
let mut row_div = div()
.id(element_id)
.flex()
.flex_row()
.items_center()
.h(px(ROW_HEIGHT))
.w_full()
.pl(px(depth * INDENT_PX))
.text_size(px(13.0))
.hover(|s| s.bg(theme.bg_row_hover));
if let Some(bg) = row_bg {
row_div = row_div.bg(bg);
}
// Chevron — área propia, click stop_propagation para no disparar el
// body click.
let chevron_id: ElementId =
SharedString::from(format!("chev::{}", id_for_chev)).into();
let chevron = {
let entity = entity.clone();
let id = id_for_chev.clone();
div()
.id(chevron_id)
.w(px(CHEVRON_PX))
.h_full()
.flex()
.items_center()
.justify_center()
.text_color(theme.fg_muted)
.text_size(px(11.0))
.child(SharedString::from(chevron_glyph.to_string()))
.when(is_branch, |this| {
this.on_click(move |click, _w, cx| {
cx.stop_propagation();
entity.update(cx, |tree, cx| {
tree.handle_chevron_click(id.clone(), click, cx);
});
})
})
};
// Body — icono opcional + label, captura el click primario.
let body = {
let entity_body = entity.clone();
let entity_ctx = entity.clone();
let id_body = id_for_body.clone();
let id_ctx = id_for_ctx.clone();
let body_id: ElementId =
SharedString::from(format!("body::{}", id_for_body)).into();
let mut content = div()
.id(body_id)
.flex()
.flex_row()
.items_center()
.gap(px(4.0))
.px(px(4.0))
.flex_grow()
.h_full()
.on_click(move |click, _w, cx| {
entity_body.update(cx, |tree, cx| {
tree.handle_row_click(id_body.clone(), click, cx);
});
})
.on_mouse_down(
MouseButton::Right,
move |e: &MouseDownEvent, _w, cx| {
cx.stop_propagation();
entity_ctx.update(cx, |tree, cx| {
tree.handle_right_click(Some(id_ctx.clone()), e, cx);
});
},
);
if !icon.is_empty() {
content = content.child(SharedString::from(icon.clone()));
}
content.child(SharedString::from(label.clone()))
};
row_div.child(chevron).child(body)
}