From ced5853154b41aefd8e16f57e31f0d3ddfc3a56c Mon Sep 17 00:00:00 2001 From: sergio Date: Wed, 20 May 2026 16:31:12 +0000 Subject: [PATCH] =?UTF-8?q?feat(fana):=20backend=20GPUI=20+=20app=20?= =?UTF-8?q?=E2=80=94=20editor=20de=20escritura=20DAG?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit fana-editor-gpui: EdgesElement pinta los conectores de dependencia como paths; editor_view compone bloques de átomo (divs absolutos coloreados por coherencia) + osciloscopio del sidepane. RenderPlan ahora lleva su LayoutConfig para que el backend sea autosuficiente. app fana: ventana con un relato de ejemplo (rama principal + alterna), botón «Mutar raíz» que dispara la onda de choque lógica (propagate_mutation), «Re-validar todo», leyenda y estadísticas. Co-Authored-By: Claude Opus 4.7 --- Cargo.lock | 23 ++ Cargo.toml | 2 + crates/apps/fana/Cargo.toml | 23 ++ crates/apps/fana/src/main.rs | 267 ++++++++++++++++++ .../modules/fana/fana-editor-gpui/Cargo.toml | 13 + .../modules/fana/fana-editor-gpui/src/lib.rs | 226 +++++++++++++++ .../modules/fana/fana-render-plan/src/lib.rs | 4 + 7 files changed, 558 insertions(+) create mode 100644 crates/apps/fana/Cargo.toml create mode 100644 crates/apps/fana/src/main.rs create mode 100644 crates/modules/fana/fana-editor-gpui/Cargo.toml create mode 100644 crates/modules/fana/fana-editor-gpui/src/lib.rs diff --git a/Cargo.lock b/Cargo.lock index c9e1546..57eab1b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3999,6 +3999,20 @@ version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" +[[package]] +name = "fana" +version = "0.1.0" +dependencies = [ + "fana-core", + "fana-editor-gpui", + "fana-graph", + "fana-render-plan", + "gpui", + "nahual-launcher", + "nahual-theme", + "uuid", +] + [[package]] name = "fana-core" version = "0.1.0" @@ -4008,6 +4022,15 @@ dependencies = [ "uuid", ] +[[package]] +name = "fana-editor-gpui" +version = "0.1.0" +dependencies = [ + "fana-render-plan", + "gpui", + "nahual-theme", +] + [[package]] name = "fana-graph" version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml index 7a97a82..5038407 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -167,6 +167,7 @@ members = [ "crates/modules/fana/fana-core", "crates/modules/fana/fana-graph", "crates/modules/fana/fana-render-plan", + "crates/modules/fana/fana-editor-gpui", "crates/modules/fana/fana-store", "crates/modules/fana/fana-semantic", "crates/modules/fana/fana-md", @@ -227,6 +228,7 @@ members = [ "crates/apps/cosmobiologia-cli", "crates/apps/cosmobiologia-server", "crates/apps/dominium", + "crates/apps/fana", ] [workspace.package] diff --git a/crates/apps/fana/Cargo.toml b/crates/apps/fana/Cargo.toml new file mode 100644 index 0000000..481d1c3 --- /dev/null +++ b/crates/apps/fana/Cargo.toml @@ -0,0 +1,23 @@ +[package] +name = "fana" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +authors.workspace = true +publish.workspace = true +description = "fana — editor de escritura DAG: ventana GPUI con el documento como grafo de átomos narrativos, conectores de dependencia y osciloscopio de coherencia." + +[[bin]] +name = "fana" +path = "src/main.rs" + +[dependencies] +fana-core = { path = "../../modules/fana/fana-core" } +fana-graph = { path = "../../modules/fana/fana-graph" } +fana-render-plan = { path = "../../modules/fana/fana-render-plan" } +fana-editor-gpui = { path = "../../modules/fana/fana-editor-gpui" } +nahual-theme = { path = "../../modules/nahual/libs/theme" } +nahual-launcher = { path = "../../modules/nahual/libs/launcher" } +gpui = { workspace = true } +uuid = { workspace = true } diff --git a/crates/apps/fana/src/main.rs b/crates/apps/fana/src/main.rs new file mode 100644 index 0000000..6a1e476 --- /dev/null +++ b/crates/apps/fana/src/main.rs @@ -0,0 +1,267 @@ +//! `fana` — el editor de escritura DAG, ventana GPUI. +//! +//! Compone la cadena de fana: +//! +//! ```text +//! fana-core ─► fana-graph ─► fana-render-plan ─► +//! fana-editor-gpui ─► [esta ventana] +//! ``` +//! +//! El documento no es un texto plano sino un grafo de átomos +//! narrativos. La ventana lo muestra en columnas por rama, con los +//! conectores de dependencia y el osciloscopio de coherencia. El botón +//! «Mutar raíz» reescribe el átomo origen y dispara la onda de choque +//! lógica: todo descendiente cae a «por evaluar». + +use fana_core::{CoherenceState, NarrativeAtom}; +use fana_editor_gpui::{editor_view, tone_color}; +use fana_graph::NarrativeGraph; +use fana_render_plan::{build_plan, CoherenceTone, LayoutConfig}; +use gpui::{div, prelude::*, px, Context, IntoElement, Render, SharedString, Window}; +use nahual_launcher::launch_app; +use nahual_theme::Theme; +use uuid::Uuid; + +/// Estado del editor. +struct Fana { + graph: NarrativeGraph, + /// Átomo raíz — el que muta el botón de demostración. + root: Uuid, + /// Cuántas veces se mutó la raíz (para variar el texto nuevo). + mutations: u32, +} + +impl Fana { + fn new(_cx: &mut Context) -> Self { + let (graph, root) = seed_document(); + Self { graph, root, mutations: 0 } + } + + /// Reescribe la raíz y propaga la onda de choque a sus descendientes. + fn mutate_root(&mut self) { + self.mutations += 1; + let nuevo = format!( + "Capítulo 1 — versión {}: el viajero nunca llegó al puerto.", + self.mutations + ); + if let Some(atom) = self.graph.get_mut(self.root) { + atom.set_content(nuevo); // marca la raíz como PendingEvaluation + } + // Marca en cascada todo descendiente transitivo. + self.graph.propagate_mutation(self.root); + } + + /// Devuelve todos los átomos a estado coherente. + fn revalidate(&mut self) { + let ids: Vec = self.graph.atoms().map(|a| a.id).collect(); + for id in ids { + if let Some(atom) = self.graph.get_mut(id) { + atom.coherence = CoherenceState::Valid; + } + } + } + + /// Cuenta átomos en cada estado de coherencia: `(pendientes, conflictos)`. + fn coherence_counts(&self) -> (usize, usize) { + let mut pending = 0; + let mut conflict = 0; + for a in self.graph.atoms() { + match a.coherence { + CoherenceState::PendingEvaluation => pending += 1, + CoherenceState::InConflict { .. } => conflict += 1, + CoherenceState::Valid => {} + } + } + (pending, conflict) + } +} + +/// Construye el documento de ejemplo: un relato corto con una rama +/// alterna. Devuelve el grafo y el id de la raíz. +fn seed_document() -> (NarrativeGraph, Uuid) { + let mut root = NarrativeAtom::new( + "Capítulo 1 — el viajero llega al puerto al amanecer.", + "principal", + ); + root.semantic_vectors.insert("calma".into(), 0.6); + let root_id = root.id; + + let mut posada = NarrativeAtom::new( + "El posadero le ofrece cuarto y un vaso de vino tibio.", + "principal", + ) + .depends_on(root_id); + posada.semantic_vectors.insert("calma".into(), 0.4); + posada.semantic_vectors.insert("misterio".into(), 0.3); + let posada_id = posada.id; + + let mut pasos = NarrativeAtom::new( + "Por la noche escucha pasos lentos en el pasillo.", + "principal", + ) + .depends_on(posada_id); + pasos.semantic_vectors.insert("misterio".into(), 0.9); + pasos.semantic_vectors.insert("miedo".into(), 0.7); + let pasos_id = pasos.id; + + let mut puerta = NarrativeAtom::new( + "Al amanecer, la puerta de su cuarto está entreabierta.", + "principal", + ) + .depends_on(pasos_id); + puerta.semantic_vectors.insert("miedo".into(), 1.0); + puerta.coherence = CoherenceState::InConflict { + origin: pasos_id, + reason: "el amanecer ya se narró en el capítulo siguiente".into(), + }; + + // Rama alterna: el viajero rechaza la posada. + let mut muelle = NarrativeAtom::new( + "Pero el viajero rechaza el cuarto y duerme sobre el muelle.", + "alterna", + ) + .depends_on(posada_id); + muelle.semantic_vectors.insert("soledad".into(), 0.8); + + let graph = NarrativeGraph::from_atoms([root, posada, pasos, puerta, muelle]); + (graph, root_id) +} + +/// Fila de leyenda: muestra el color de un tono y su etiqueta. +fn legend_row(tone: CoherenceTone, label: &str, theme: &Theme) -> impl IntoElement { + div() + .flex() + .flex_row() + .items_center() + .gap(px(8.)) + .child(div().w(px(12.)).h(px(12.)).rounded(px(3.)).bg(tone_color(tone))) + .child( + div() + .text_size(px(12.)) + .text_color(theme.fg_muted) + .child(SharedString::from(label.to_string())), + ) +} + +/// Fila etiqueta/valor del panel. +fn stat_row(label: &str, value: String, theme: &Theme) -> impl IntoElement { + div() + .flex() + .flex_row() + .justify_between() + .child(div().text_color(theme.fg_muted).child(SharedString::from(label.to_string()))) + .child(div().text_color(theme.fg_text).child(SharedString::from(value))) +} + +impl Render for Fana { + fn render(&mut self, _w: &mut Window, cx: &mut Context) -> impl IntoElement { + let theme = Theme::global(cx).clone(); + let panel = gpui::hsla(220.0 / 360.0, 0.18, 0.10, 1.0); + let chip = gpui::hsla(220.0 / 360.0, 0.16, 0.16, 1.0); + let (pending, conflict) = self.coherence_counts(); + + let plan = build_plan(&self.graph, &LayoutConfig::default()); + + // --- Barra de estado --- + let status = div() + .h(px(34.)) + .flex() + .flex_row() + .items_center() + .justify_between() + .px(px(14.)) + .bg(panel) + .text_color(theme.fg_text) + .child("fana · editor de escritura DAG") + .child( + div() + .text_color(theme.fg_muted) + .child(SharedString::from(format!("{} átomos", self.graph.len()))), + ); + + // --- Lienzo del editor (con scroll) --- + let canvas = div() + .id("editor-scroll") + .flex_1() + .overflow_x_scroll() + .overflow_y_scroll() + .bg(theme.bg_app) + .child(editor_view(&plan, &theme)); + + // --- Botones (los listeners se cablean abajo con cx.listener) --- + let btn_mutar = div() + .id("mutar") + .px(px(10.)) + .py(px(7.)) + .bg(chip) + .rounded(px(5.)) + .text_color(theme.fg_text) + .cursor_pointer() + .hover(|s| s.bg(theme.bg_row_hover)) + .child("⚡ Mutar raíz") + .on_click(cx.listener(|fana, _ev, _w, cx| { + fana.mutate_root(); + cx.notify(); + })); + let btn_revalidar = div() + .id("revalidar") + .px(px(10.)) + .py(px(7.)) + .bg(chip) + .rounded(px(5.)) + .text_color(theme.fg_text) + .cursor_pointer() + .hover(|s| s.bg(theme.bg_row_hover)) + .child("✓ Re-validar todo") + .on_click(cx.listener(|fana, _ev, _w, cx| { + fana.revalidate(); + cx.notify(); + })); + + // --- Panel lateral --- + let side = div() + .w(px(240.)) + .flex() + .flex_col() + .gap(px(10.)) + .p(px(12.)) + .bg(panel) + .text_color(theme.fg_text) + .child(div().text_color(theme.fg_muted).child("[DOCUMENTO]")) + .child(btn_mutar) + .child(btn_revalidar) + .child(div().h(px(1.)).bg(theme.border)) + .child(stat_row("Átomos", format!("{}", self.graph.len()), &theme)) + .child(stat_row("Por evaluar", format!("{pending}"), &theme)) + .child(stat_row("En conflicto", format!("{conflict}"), &theme)) + .child(div().h(px(1.)).bg(theme.border)) + .child(div().text_color(theme.fg_muted).child("coherencia")) + .child(legend_row(CoherenceTone::Valid, "coherente", &theme)) + .child(legend_row(CoherenceTone::Pending, "por evaluar", &theme)) + .child(legend_row(CoherenceTone::Conflict, "en conflicto", &theme)) + .child(div().h(px(1.)).bg(theme.border)) + .child( + div() + .text_size(px(11.)) + .text_color(theme.fg_muted) + .child( + "«Mutar raíz» reescribe el átomo origen: la onda \ + de choque marca cada descendiente como «por \ + evaluar».", + ), + ); + + // --- Composición --- + div() + .size_full() + .flex() + .flex_col() + .bg(theme.bg_app) + .child(status) + .child(div().flex().flex_row().flex_1().child(canvas).child(side)) + } +} + +fn main() { + launch_app("brahman · fana", (1180., 760.), Fana::new); +} diff --git a/crates/modules/fana/fana-editor-gpui/Cargo.toml b/crates/modules/fana/fana-editor-gpui/Cargo.toml new file mode 100644 index 0000000..ed045fc --- /dev/null +++ b/crates/modules/fana/fana-editor-gpui/Cargo.toml @@ -0,0 +1,13 @@ +[package] +name = "fana-editor-gpui" +version.workspace = true +edition.workspace = true +license.workspace = true +authors.workspace = true +publish.workspace = true +description = "fana — backend GPUI del editor DAG: pinta un RenderPlan como bloques de átomo, conectores de dependencia y osciloscopio de coherencia." + +[dependencies] +fana-render-plan = { path = "../fana-render-plan" } +nahual-theme = { path = "../../nahual/libs/theme" } +gpui = { workspace = true } diff --git a/crates/modules/fana/fana-editor-gpui/src/lib.rs b/crates/modules/fana/fana-editor-gpui/src/lib.rs new file mode 100644 index 0000000..1f26f07 --- /dev/null +++ b/crates/modules/fana/fana-editor-gpui/src/lib.rs @@ -0,0 +1,226 @@ +//! `fana-editor-gpui` — el backend GPUI del editor DAG. +//! +//! Consume un [`RenderPlan`] de `fana-render-plan` y lo vuelca a la +//! pantalla: los bloques de átomo y las marcas del osciloscopio son +//! `div`s posicionados en absoluto (texto y estilo nativos); los +//! conectores de dependencia van por un [`EdgesElement`] que pinta +//! paths debajo de todo. +//! +//! Es el único crate de fana visual que toca `gpui` — el resto de la +//! cadena (`core`, `graph`, `render-plan`) es agnóstico. + +#![forbid(unsafe_code)] + +use std::panic; + +use fana_render_plan::{CoherenceTone, Edge, RenderPlan}; +use gpui::{ + div, point, prelude::*, px, App, Bounds, Element, ElementId, GlobalElementId, Hsla, + InspectorElementId, IntoElement, LayoutId, PathBuilder, Pixels, SharedString, Style, Window, +}; +use nahual_theme::Theme; + +/// Color semántico de un estado de coherencia. Fijo, no temático: el +/// rojo de "conflicto" y el ámbar de "pendiente" son señales, no estilo. +pub fn tone_color(tone: CoherenceTone) -> Hsla { + match tone { + CoherenceTone::Valid => gpui::hsla(145.0 / 360.0, 0.42, 0.55, 1.0), + CoherenceTone::Pending => gpui::hsla(42.0 / 360.0, 0.82, 0.58, 1.0), + CoherenceTone::Conflict => gpui::hsla(2.0 / 360.0, 0.70, 0.58, 1.0), + } +} + +/// Etiqueta corta de un tono — para leyendas. +pub fn tone_label(tone: CoherenceTone) -> &'static str { + match tone { + CoherenceTone::Valid => "coherente", + CoherenceTone::Pending => "por evaluar", + CoherenceTone::Conflict => "en conflicto", + } +} + +/// `Element` que pinta los conectores de dependencia como líneas. +/// +/// Llena su contenedor: posiciona cada arista relativa al origen de sus +/// bounds, igual que los `div`s absolutos de los bloques. +pub struct EdgesElement { + edges: Vec, + color: Hsla, + width: f32, +} + +impl EdgesElement { + pub fn new(edges: Vec, color: Hsla, width: f32) -> Self { + Self { edges, color, width } + } +} + +impl IntoElement for EdgesElement { + type Element = Self; + fn into_element(self) -> Self::Element { + self + } +} + +impl Element for EdgesElement { + type RequestLayoutState = (); + type PrepaintState = (); + + fn id(&self) -> Option { + None + } + + fn source_location(&self) -> Option<&'static panic::Location<'static>> { + None + } + + fn request_layout( + &mut self, + _id: Option<&GlobalElementId>, + _inspector_id: Option<&InspectorElementId>, + window: &mut Window, + cx: &mut App, + ) -> (LayoutId, Self::RequestLayoutState) { + let mut style = Style::default(); + style.size.width = gpui::Length::Definite(gpui::DefiniteLength::Fraction(1.0)); + style.size.height = gpui::Length::Definite(gpui::DefiniteLength::Fraction(1.0)); + (window.request_layout(style, [], cx), ()) + } + + fn prepaint( + &mut self, + _id: Option<&GlobalElementId>, + _inspector_id: Option<&InspectorElementId>, + _bounds: Bounds, + _layout: &mut Self::RequestLayoutState, + _window: &mut Window, + _cx: &mut App, + ) -> Self::PrepaintState { + } + + fn paint( + &mut self, + _id: Option<&GlobalElementId>, + _inspector_id: Option<&InspectorElementId>, + bounds: Bounds, + _layout: &mut Self::RequestLayoutState, + _prepaint: &mut Self::PrepaintState, + window: &mut Window, + _cx: &mut App, + ) { + let ox = bounds.origin.x; + let oy = bounds.origin.y; + for e in &self.edges { + let mut pb = PathBuilder::stroke(px(self.width)); + pb.move_to(point(ox + px(e.x1), oy + px(e.y1))); + // Codo en S: baja recto, cruza, baja recto — legible aunque + // las columnas estén separadas. + let mid_y = oy + px((e.y1 + e.y2) * 0.5); + pb.line_to(point(ox + px(e.x1), mid_y)); + pb.line_to(point(ox + px(e.x2), mid_y)); + pb.line_to(point(ox + px(e.x2), oy + px(e.y2))); + if let Ok(path) = pb.build() { + window.paint_path(path, self.color); + } + } + } +} + +/// Bloque de un átomo: caja posicionada en absoluto con su preview. +fn block_div(b: &fana_render_plan::AtomBlock, theme: &Theme) -> impl IntoElement { + div() + .absolute() + .left(px(b.x)) + .top(px(b.y)) + .w(px(b.w)) + .h(px(b.h)) + .flex() + .flex_col() + .gap(px(3.)) + .p(px(8.)) + .bg(theme.bg_panel) + .border_2() + .border_color(tone_color(b.tone)) + .rounded(px(5.)) + .child( + div() + .text_size(px(10.)) + .text_color(theme.fg_muted) + .child(SharedString::from(format!( + "{} · profundidad {} · {}", + b.branch, + b.depth, + tone_label(b.tone) + ))), + ) + .child( + div() + .text_size(px(13.)) + .text_color(theme.fg_text) + .child(SharedString::from(b.preview.clone())), + ) +} + +/// Marca del osciloscopio de coherencia en el sidepane. +fn mark_div(m: &fana_render_plan::SidepaneMark, cfg: &fana_render_plan::LayoutConfig) -> impl IntoElement { + let usable = (cfg.sidepane_width - 8.0).max(4.0); + let w = (m.intensity * usable).max(3.0); + div() + .absolute() + .left(px(cfg.margin)) + .top(px(m.y)) + .w(px(w)) + .h(px(m.h)) + .bg(tone_color(m.tone)) + .rounded(px(3.)) +} + +/// Compone el plan completo en un árbol GPUI: capa de conectores al +/// fondo, bloques y marcas encima. El resultado mide exactamente el +/// contenido — envolverlo en un contenedor con scroll para documentos +/// largos. +pub fn editor_view(plan: &RenderPlan, theme: &Theme) -> impl IntoElement { + let cfg = plan.config; + let content_w = plan + .blocks + .iter() + .map(|b| b.x + b.w) + .fold(0.0f32, f32::max) + + cfg.margin; + + let blocks: Vec<_> = plan.blocks.iter().map(|b| block_div(b, theme)).collect(); + let marks: Vec<_> = plan.sidepane.iter().map(|m| mark_div(m, &cfg)).collect(); + + div() + .relative() + .w(px(content_w.max(cfg.margin * 2.0))) + .h(px(plan.content_height.max(cfg.margin * 2.0))) + .child( + div() + .absolute() + .left(px(0.)) + .top(px(0.)) + .size_full() + .child(EdgesElement::new(plan.edges.clone(), theme.border_strong, 1.6)), + ) + .children(blocks) + .children(marks) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn tones_have_distinct_colors() { + let v = tone_color(CoherenceTone::Valid); + let p = tone_color(CoherenceTone::Pending); + let c = tone_color(CoherenceTone::Conflict); + assert!(v != p && p != c && v != c); + } + + #[test] + fn tone_labels_are_set() { + assert_eq!(tone_label(CoherenceTone::Conflict), "en conflicto"); + } +} diff --git a/crates/modules/fana/fana-render-plan/src/lib.rs b/crates/modules/fana/fana-render-plan/src/lib.rs index 21d4c23..57282b0 100644 --- a/crates/modules/fana/fana-render-plan/src/lib.rs +++ b/crates/modules/fana/fana-render-plan/src/lib.rs @@ -124,6 +124,9 @@ pub struct RenderPlan { pub sidepane: Vec, /// Alto total del contenido — el backend lo usa para el scroll. pub content_height: f32, + /// Config con la que se construyó — el backend lee de aquí los + /// márgenes y el ancho del sidepane sin recibirlos por separado. + pub config: LayoutConfig, } /// Profundidad topológica de cada átomo: 0 para las raíces, `1 + máx` @@ -254,6 +257,7 @@ pub fn build_plan(graph: &NarrativeGraph, cfg: &LayoutConfig) -> RenderPlan { edges, sidepane, content_height: cfg.margin * 2.0 + order.len() as f32 * row_stride, + config: *cfg, } }