feat(fana): backend GPUI + app — editor de escritura DAG
1//! `fana` — el editor de escritura DAG, ventana GPUI.
2//!
3//! Compone la cadena de fana:
4//!
5//! ```text
6//! fana-core ─► fana-graph ─► fana-render-plan ─►
7//! fana-editor-gpui ─► [esta ventana]
8//! ```
9//!
10//! El documento no es un texto plano sino un grafo de átomos
11//! narrativos. La ventana lo muestra en columnas por rama, con los
12//! conectores de dependencia y el osciloscopio de coherencia. El botón
13//! «Mutar raíz» reescribe el átomo origen y dispara la onda de choque
14//! lógica: todo descendiente cae a «por evaluar».
15
16use fana_core::{CoherenceState, NarrativeAtom};
17use fana_editor_gpui::{editor_view, tone_color};
18use fana_graph::NarrativeGraph;
19use fana_render_plan::{build_plan, CoherenceTone, LayoutConfig};
20use gpui::{div, prelude::*, px, Context, IntoElement, Render, SharedString, Window};
21use nahual_launcher::launch_app;
22use nahual_theme::Theme;
23use uuid::Uuid;
24
25/// Estado del editor.
26struct Fana {
27 graph: NarrativeGraph,
28 /// Átomo raíz — el que muta el botón de demostración.
29 root: Uuid,
30 /// Cuántas veces se mutó la raíz (para variar el texto nuevo).
31 mutations: u32,
32}
33
34impl Fana {
35 fn new(_cx: &mut Context<Self>) -> Self {
36 let (graph, root) = seed_document();
37 Self { graph, root, mutations: 0 }
38 }
39
40 /// Reescribe la raíz y propaga la onda de choque a sus descendientes.
41 fn mutate_root(&mut self) {
42 self.mutations += 1;
43 let nuevo = format!(
44 "Capítulo 1 — versión {}: el viajero nunca llegó al puerto.",
45 self.mutations
46 );
47 if let Some(atom) = self.graph.get_mut(self.root) {
48 atom.set_content(nuevo); // marca la raíz como PendingEvaluation
49 }
50 // Marca en cascada todo descendiente transitivo.
51 self.graph.propagate_mutation(self.root);
52 }
53
54 /// Devuelve todos los átomos a estado coherente.
55 fn revalidate(&mut self) {
56 let ids: Vec<Uuid> = self.graph.atoms().map(|a| a.id).collect();
57 for id in ids {
58 if let Some(atom) = self.graph.get_mut(id) {
59 atom.coherence = CoherenceState::Valid;
60 }
61 }
62 }
63
64 /// Cuenta átomos en cada estado de coherencia: `(pendientes, conflictos)`.
65 fn coherence_counts(&self) -> (usize, usize) {
66 let mut pending = 0;
67 let mut conflict = 0;
68 for a in self.graph.atoms() {
69 match a.coherence {
70 CoherenceState::PendingEvaluation => pending += 1,
71 CoherenceState::InConflict { .. } => conflict += 1,
72 CoherenceState::Valid => {}
73 }
74 }
75 (pending, conflict)
76 }
77}
78
79/// Construye el documento de ejemplo: un relato corto con una rama
80/// alterna. Devuelve el grafo y el id de la raíz.
81fn seed_document() -> (NarrativeGraph, Uuid) {
82 let mut root = NarrativeAtom::new(
83 "Capítulo 1 — el viajero llega al puerto al amanecer.",
84 "principal",
85 );
86 root.semantic_vectors.insert("calma".into(), 0.6);
87 let root_id = root.id;
88
89 let mut posada = NarrativeAtom::new(
90 "El posadero le ofrece cuarto y un vaso de vino tibio.",
91 "principal",
92 )
93 .depends_on(root_id);
94 posada.semantic_vectors.insert("calma".into(), 0.4);
95 posada.semantic_vectors.insert("misterio".into(), 0.3);
96 let posada_id = posada.id;
97
98 let mut pasos = NarrativeAtom::new(
99 "Por la noche escucha pasos lentos en el pasillo.",
100 "principal",
101 )
102 .depends_on(posada_id);
103 pasos.semantic_vectors.insert("misterio".into(), 0.9);
104 pasos.semantic_vectors.insert("miedo".into(), 0.7);
105 let pasos_id = pasos.id;
106
107 let mut puerta = NarrativeAtom::new(
108 "Al amanecer, la puerta de su cuarto está entreabierta.",
109 "principal",
110 )
111 .depends_on(pasos_id);
112 puerta.semantic_vectors.insert("miedo".into(), 1.0);
113 puerta.coherence = CoherenceState::InConflict {
114 origin: pasos_id,
115 reason: "el amanecer ya se narró en el capítulo siguiente".into(),
116 };
117
118 // Rama alterna: el viajero rechaza la posada.
119 let mut muelle = NarrativeAtom::new(
120 "Pero el viajero rechaza el cuarto y duerme sobre el muelle.",
121 "alterna",
122 )
123 .depends_on(posada_id);
124 muelle.semantic_vectors.insert("soledad".into(), 0.8);
125
126 let graph = NarrativeGraph::from_atoms([root, posada, pasos, puerta, muelle]);
127 (graph, root_id)
128}
129
130/// Fila de leyenda: muestra el color de un tono y su etiqueta.
131fn legend_row(tone: CoherenceTone, label: &str, theme: &Theme) -> impl IntoElement {
132 div()
133 .flex()
134 .flex_row()
135 .items_center()
136 .gap(px(8.))
137 .child(div().w(px(12.)).h(px(12.)).rounded(px(3.)).bg(tone_color(tone)))
138 .child(
139 div()
140 .text_size(px(12.))
141 .text_color(theme.fg_muted)
142 .child(SharedString::from(label.to_string())),
143 )
144}
145
146/// Fila etiqueta/valor del panel.
147fn stat_row(label: &str, value: String, theme: &Theme) -> impl IntoElement {
148 div()
149 .flex()
150 .flex_row()
151 .justify_between()
152 .child(div().text_color(theme.fg_muted).child(SharedString::from(label.to_string())))
153 .child(div().text_color(theme.fg_text).child(SharedString::from(value)))
154}
155
156impl Render for Fana {
157 fn render(&mut self, _w: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
158 let theme = Theme::global(cx).clone();
159 let panel = gpui::hsla(220.0 / 360.0, 0.18, 0.10, 1.0);
160 let chip = gpui::hsla(220.0 / 360.0, 0.16, 0.16, 1.0);
161 let (pending, conflict) = self.coherence_counts();
162
163 let plan = build_plan(&self.graph, &LayoutConfig::default());
164
165 // --- Barra de estado ---
166 let status = div()
167 .h(px(34.))
168 .flex()
169 .flex_row()
170 .items_center()
171 .justify_between()
172 .px(px(14.))
173 .bg(panel)
174 .text_color(theme.fg_text)
175 .child("fana · editor de escritura DAG")
176 .child(
177 div()
178 .text_color(theme.fg_muted)
179 .child(SharedString::from(format!("{} átomos", self.graph.len()))),
180 );
181
182 // --- Lienzo del editor (con scroll) ---
183 let canvas = div()
184 .id("editor-scroll")
185 .flex_1()
186 .overflow_x_scroll()
187 .overflow_y_scroll()
188 .bg(theme.bg_app)
189 .child(editor_view(&plan, &theme));
190
191 // --- Botones (los listeners se cablean abajo con cx.listener) ---
192 let btn_mutar = div()
193 .id("mutar")
194 .px(px(10.))
195 .py(px(7.))
196 .bg(chip)
197 .rounded(px(5.))
198 .text_color(theme.fg_text)
199 .cursor_pointer()
200 .hover(|s| s.bg(theme.bg_row_hover))
201 .child("⚡ Mutar raíz")
202 .on_click(cx.listener(|fana, _ev, _w, cx| {
203 fana.mutate_root();
204 cx.notify();
205 }));
206 let btn_revalidar = div()
207 .id("revalidar")
208 .px(px(10.))
209 .py(px(7.))
210 .bg(chip)
211 .rounded(px(5.))
212 .text_color(theme.fg_text)
213 .cursor_pointer()
214 .hover(|s| s.bg(theme.bg_row_hover))
215 .child("✓ Re-validar todo")
216 .on_click(cx.listener(|fana, _ev, _w, cx| {
217 fana.revalidate();
218 cx.notify();
219 }));
220
221 // --- Panel lateral ---
222 let side = div()
223 .w(px(240.))
224 .flex()
225 .flex_col()
226 .gap(px(10.))
227 .p(px(12.))
228 .bg(panel)
229 .text_color(theme.fg_text)
230 .child(div().text_color(theme.fg_muted).child("[DOCUMENTO]"))
231 .child(btn_mutar)
232 .child(btn_revalidar)
233 .child(div().h(px(1.)).bg(theme.border))
234 .child(stat_row("Átomos", format!("{}", self.graph.len()), &theme))
235 .child(stat_row("Por evaluar", format!("{pending}"), &theme))
236 .child(stat_row("En conflicto", format!("{conflict}"), &theme))
237 .child(div().h(px(1.)).bg(theme.border))
238 .child(div().text_color(theme.fg_muted).child("coherencia"))
239 .child(legend_row(CoherenceTone::Valid, "coherente", &theme))
240 .child(legend_row(CoherenceTone::Pending, "por evaluar", &theme))
241 .child(legend_row(CoherenceTone::Conflict, "en conflicto", &theme))
242 .child(div().h(px(1.)).bg(theme.border))
243 .child(
244 div()
245 .text_size(px(11.))
246 .text_color(theme.fg_muted)
247 .child(
248 "«Mutar raíz» reescribe el átomo origen: la onda \
249 de choque marca cada descendiente como «por \
250 evaluar».",
251 ),
252 );
253
254 // --- Composición ---
255 div()
256 .size_full()
257 .flex()
258 .flex_col()
259 .bg(theme.bg_app)
260 .child(status)
261 .child(div().flex().flex_row().flex_1().child(canvas).child(side))
262 }
263}
264
265fn main() {
266 launch_app("brahman · fana", (1180., 760.), Fana::new);
267}