gioser

tawasuyu/brahman

Rust 483TOML 191Markdown 50JSON 176777 Definiciones

feat(shuma): shuma-shell-render — draw-plan del Lienzo de Contexto

Layout agnóstico del grafo de intenciones del shell:

- layout(SessionGraph) → CanvasPlan: cada comando %cN es un NodeBox
  ubicado en una columna por su profundidad de dependencia
  (longest-path); cada ref %pN/%cN que consume genera una Edge hacia
  el comando que la produjo. Nodos colapsados se dibujan retraídos.
- paint(plan, canvas) → render directo contra pineal-render: aristas
  al fondo, cajas con borde coloreado por estado (ámbar/verde/rojo).

4 tests verdes (columnas por dependencia, aristas de buffer, comandos
independientes en col 0, paint emite draw calls). cargo check verde.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

ed651d6ac585706b68421e73846218fec564e1de · por sergio · (hace 5 meses) · padre cd3b41a40 · ver el árbol en este commit

Delta semántico · 15

crates/modules/shuma/shuma-shell-render/src/lib.rs

4 archivos · +241 −0

diff --git a/Cargo.lock b/Cargo.lock
index 23fd070..9af465b 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -11261,6 +11261,14 @@ dependencies = [
  "ulid",
 ]
 
+[[package]]
+name = "shuma-shell-render"
+version = "0.1.0"
+dependencies = [
+ "pineal-render",
+ "shuma-intent",
+]
+
 [[package]]
 name = "signal-hook-registry"
 version = "1.4.8"
diff --git a/Cargo.toml b/Cargo.toml
index 3f83bd0..395c52f 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -140,6 +140,7 @@ members = [
     "crates/modules/shuma/shuma-discern",
     "crates/modules/shuma/shuma-core",
     "crates/modules/shuma/shuma-intent",
+    "crates/modules/shuma/shuma-shell-render",
 
     # ============================================================
     # modules/dominium/ — Simulador psicológico de campo medio
diff --git a/crates/modules/shuma/shuma-shell-render/Cargo.toml b/crates/modules/shuma/shuma-shell-render/Cargo.toml
new file mode 100644
index 0000000..f439ae0
--- /dev/null
+++ b/crates/modules/shuma/shuma-shell-render/Cargo.toml
@@ -0,0 +1,12 @@
+[package]
+name = "shuma-shell-render"
+version.workspace = true
+edition.workspace = true
+license.workspace = true
+authors.workspace = true
+publish.workspace = true
+description = "shuma — draw-plan agnóstico del Lienzo de Contexto: layout del grafo de intenciones (columnas por dependencia) + paint contra pineal-render."
+
+[dependencies]
+shuma-intent = { path = "../shuma-intent" }
+pineal-render = { path = "../../pineal/render" }
diff --git a/crates/modules/shuma/shuma-shell-render/src/lib.rs b/crates/modules/shuma/shuma-shell-render/src/lib.rs
new file mode 100644
index 0000000..7237de4
--- /dev/null
+++ b/crates/modules/shuma/shuma-shell-render/src/lib.rs
@@ -0,0 +1,220 @@
+//! `shuma-shell-render` — draw-plan agnóstico del Lienzo de Contexto.
+//!
+//! Toma un [`SessionGraph`] y computa el layout del grafo de intenciones:
+//! cada comando `%cN` es una caja, ubicada en una columna según su
+//! profundidad de dependencia (longest-path); cada referencia `%pN`/`%cN`
+//! que un comando consume es una arista hacia el comando que la produjo.
+//!
+//! Agnóstico de UI: el front-end GPUI consume el [`CanvasPlan`] y lo
+//! dibuja; [`paint`] ofrece un render directo contra `pineal_render`.
+
+#![forbid(unsafe_code)]
+
+use pineal_render::{Canvas, Color, Point, Rect, StrokeStyle};
+use shuma_intent::{Intention, NodeStatus, SessionGraph};
+
+/// Una caja de comando ya posicionada en el lienzo.
+#[derive(Debug, Clone)]
+pub struct NodeBox {
+    pub command_id: u32,
+    /// Texto de la intención (el caller lo trunca al dibujar si hace falta).
+    pub label: String,
+    pub status: NodeStatus,
+    pub collapsed: bool,
+    pub column: usize,
+    pub rect: Rect,
+}
+
+/// Una arista del lienzo: flujo de datos entre dos comandos.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub struct Edge {
+    pub from_command: u32,
+    pub to_command: u32,
+    /// `%pN` si el flujo va por un buffer; `None` si es una ref a comando.
+    pub buffer_id: Option<u32>,
+}
+
+/// El layout completo del lienzo.
+#[derive(Debug, Clone, Default)]
+pub struct CanvasPlan {
+    pub nodes: Vec<NodeBox>,
+    pub edges: Vec<Edge>,
+}
+
+impl CanvasPlan {
+    /// Caja de un comando por su id.
+    pub fn node(&self, command_id: u32) -> Option<&NodeBox> {
+        self.nodes.iter().find(|n| n.command_id == command_id)
+    }
+}
+
+/// Parámetros geométricos del layout.
+#[derive(Debug, Clone, Copy)]
+pub struct LayoutParams {
+    pub node_w: f32,
+    pub node_h: f32,
+    pub collapsed_h: f32,
+    pub col_gap: f32,
+    pub row_gap: f32,
+    pub origin: Point,
+}
+
+impl Default for LayoutParams {
+    fn default() -> Self {
+        Self {
+            node_w: 160.0,
+            node_h: 56.0,
+            collapsed_h: 22.0,
+            col_gap: 64.0,
+            row_gap: 20.0,
+            origin: Point::new(16.0, 16.0),
+        }
+    }
+}
+
+/// Computa el layout del grafo de intenciones.
+pub fn layout(graph: &SessionGraph, p: &LayoutParams) -> CanvasPlan {
+    let cmds = graph.commands();
+    let mut edges = Vec::new();
+    // Profundidad de cada comando por su id (los comandos sólo refieren
+    // resultados previos, así que recorrer en orden de id basta).
+    let mut depth: Vec<(u32, usize)> = Vec::with_capacity(cmds.len());
+
+    for c in cmds {
+        let refs = Intention::parse(&c.intention).refs();
+        let mut d = 0usize;
+        for r in refs {
+            if let Some(producer) = graph.resolve(r) {
+                edges.push(Edge {
+                    from_command: producer.id,
+                    to_command: c.id,
+                    buffer_id: producer.output_buffer,
+                });
+                let pd = depth
+                    .iter()
+                    .find(|(id, _)| *id == producer.id)
+                    .map(|(_, d)| *d)
+                    .unwrap_or(0);
+                d = d.max(pd + 1);
+            }
+        }
+        depth.push((c.id, d));
+    }
+
+    // Posiciona: columna = profundidad, fila = orden de llegada en la columna.
+    let mut rows_in_col: Vec<usize> = Vec::new();
+    let mut nodes = Vec::with_capacity(cmds.len());
+    for (c, &(_, col)) in cmds.iter().zip(&depth) {
+        while rows_in_col.len() <= col {
+            rows_in_col.push(0);
+        }
+        let row = rows_in_col[col];
+        rows_in_col[col] += 1;
+
+        let h = if c.collapsed { p.collapsed_h } else { p.node_h };
+        let x = p.origin.x + col as f32 * (p.node_w + p.col_gap);
+        let y = p.origin.y + row as f32 * (p.node_h + p.row_gap);
+        nodes.push(NodeBox {
+            command_id: c.id,
+            label: c.intention.clone(),
+            status: c.status,
+            collapsed: c.collapsed,
+            column: col,
+            rect: Rect::new(x, y, p.node_w, h),
+        });
+    }
+    CanvasPlan { nodes, edges }
+}
+
+/// Color de borde según el estado del nodo.
+fn status_color(s: NodeStatus) -> Color {
+    match s {
+        NodeStatus::Running => Color::from_hex(0xe0b341), // ámbar
+        NodeStatus::Ok => Color::from_hex(0x4caf6a),      // verde
+        NodeStatus::Failed => Color::from_hex(0xd0463b),  // rojo
+    }
+}
+
+/// Dibuja el plan contra un `Canvas`: aristas primero (al fondo), luego
+/// las cajas de comando. El texto lo dibuja el caller si quiere control
+/// de truncado/fuente.
+pub fn paint(plan: &CanvasPlan, canvas: &mut dyn Canvas) {
+    let edge_stroke = StrokeStyle::new(1.5, Color::from_hex(0x6b7280));
+    for e in &plan.edges {
+        let (Some(a), Some(b)) = (plan.node(e.from_command), plan.node(e.to_command))
+        else {
+            continue;
+        };
+        let from = Point::new(a.rect.right(), a.rect.y + a.rect.h / 2.0);
+        let to = Point::new(b.rect.x, b.rect.y + b.rect.h / 2.0);
+        canvas.stroke_line(from, to, edge_stroke);
+    }
+    for n in &plan.nodes {
+        canvas.fill_rect(n.rect, Color::from_hex(0x1c2128));
+        canvas.stroke_rect(n.rect, StrokeStyle::new(2.0, status_color(n.status)));
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    /// Sesión: c1 produce %p1; c2 lo consume.
+    fn chained_session() -> SessionGraph {
+        let mut g = SessionGraph::new();
+        let c1 = g.record("cat data.json");
+        g.complete(c1, true, 2400); // → %p1
+        let _c2 = g.record("sort | %p1");
+        g
+    }
+
+    #[test]
+    fn layout_places_dependent_in_a_later_column() {
+        let g = chained_session();
+        let plan = layout(&g, &LayoutParams::default());
+        assert_eq!(plan.nodes.len(), 2);
+        let c1 = plan.node(1).unwrap();
+        let c2 = plan.node(2).unwrap();
+        assert_eq!(c1.column, 0);
+        assert_eq!(c2.column, 1, "el que consume %p1 va una columna después");
+        assert!(c2.rect.x > c1.rect.x);
+    }
+
+    #[test]
+    fn layout_creates_an_edge_for_the_buffer_flow() {
+        let g = chained_session();
+        let plan = layout(&g, &LayoutParams::default());
+        assert_eq!(plan.edges.len(), 1);
+        assert_eq!(plan.edges[0].from_command, 1);
+        assert_eq!(plan.edges[0].to_command, 2);
+        assert_eq!(plan.edges[0].buffer_id, Some(1));
+    }
+
+    #[test]
+    fn independent_commands_share_column_zero() {
+        let mut g = SessionGraph::new();
+        g.record("ls");
+        g.record("pwd");
+        let plan = layout(&g, &LayoutParams::default());
+        assert!(plan.nodes.iter().all(|n| n.column == 0));
+        assert!(plan.edges.is_empty());
+        // Apiladas en filas distintas.
+        assert_ne!(plan.nodes[0].rect.y, plan.nodes[1].rect.y);
+    }
+
+    #[test]
+    fn paint_emits_commands_to_a_recorder() {
+        use pineal_render::{PlanRecorder, RenderCmd};
+        let g = chained_session();
+        let plan = layout(&g, &LayoutParams::default());
+        let mut rec = PlanRecorder::new();
+        paint(&plan, &mut rec);
+        let cmds = rec.into_plan().cmds;
+        // 1 línea (la arista) + 2 fill + 2 stroke (las cajas).
+        assert!(cmds.iter().any(|c| matches!(c, RenderCmd::StrokeLine { .. })));
+        assert_eq!(
+            cmds.iter().filter(|c| matches!(c, RenderCmd::FillRect { .. })).count(),
+            2
+        );
+    }
+}