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, +} + +/// El layout completo del lienzo. +#[derive(Debug, Clone, Default)] +pub struct CanvasPlan { + pub nodes: Vec, + pub edges: Vec, +} + +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 = 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 + ); + } +}