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,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