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:
@@ -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 }
|
||||
@@ -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 }
|
||||
@@ -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(¤t, &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(¤t, &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(¤t, &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(¤t, &proposed).is_empty());
|
||||
|
||||
let current_str = json!({"qty": "100"});
|
||||
let proposed_int = map(&[("qty", json!(100_i64))]);
|
||||
assert_eq!(compute_field_delta(¤t_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(¤t, &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(¤t, &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(¤t, &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(¤t, &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, ¶ms)?;
|
||||
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 }
|
||||
@@ -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)
|
||||
}
|
||||
Reference in New Issue
Block a user