feat(fana): backend GPUI + app — editor de escritura DAG

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 <noreply@anthropic.com>
This commit is contained in:
sergio
2026-05-20 16:31:12 +00:00
parent 494fb7c0bc
commit ced5853154
7 changed files with 558 additions and 0 deletions
+23
View File
@@ -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 }
+267
View File
@@ -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>) -> 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<Uuid> = 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<Self>) -> 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);
}