feat(shuma): shuma-shell-render — draw-plan del Lienzo de Contexto
1//! `shuma-shell-render` — draw-plan agnóstico del Lienzo de Contexto.
2//!
3//! Toma un [`SessionGraph`] y computa el layout del grafo de intenciones:
4//! cada comando `%cN` es una caja, ubicada en una columna según su
5//! profundidad de dependencia (longest-path); cada referencia `%pN`/`%cN`
6//! que un comando consume es una arista hacia el comando que la produjo.
7//!
8//! Agnóstico de UI: el front-end GPUI consume el [`CanvasPlan`] y lo
9//! dibuja; [`paint`] ofrece un render directo contra `pineal_render`.
10
11#![forbid(unsafe_code)]
12
13use pineal_render::{Canvas, Color, Point, Rect, StrokeStyle};
14use shuma_intent::{Intention, NodeStatus, SessionGraph};
15
16/// Una caja de comando ya posicionada en el lienzo.
17#[derive(Debug, Clone)]
18pub struct NodeBox {
19 pub command_id: u32,
20 /// Texto de la intención (el caller lo trunca al dibujar si hace falta).
21 pub label: String,
22 pub status: NodeStatus,
23 pub collapsed: bool,
24 pub column: usize,
25 pub rect: Rect,
26}
27
28/// Una arista del lienzo: flujo de datos entre dos comandos.
29#[derive(Debug, Clone, Copy, PartialEq, Eq)]
30pub struct Edge {
31 pub from_command: u32,
32 pub to_command: u32,
33 /// `%pN` si el flujo va por un buffer; `None` si es una ref a comando.
34 pub buffer_id: Option<u32>,
35}
36
37/// El layout completo del lienzo.
38#[derive(Debug, Clone, Default)]
39pub struct CanvasPlan {
40 pub nodes: Vec<NodeBox>,
41 pub edges: Vec<Edge>,
42}
43
44impl CanvasPlan {
45 /// Caja de un comando por su id.
46 pub fn node(&self, command_id: u32) -> Option<&NodeBox> {
47 self.nodes.iter().find(|n| n.command_id == command_id)
48 }
49}
50
51/// Parámetros geométricos del layout.
52#[derive(Debug, Clone, Copy)]
53pub struct LayoutParams {
54 pub node_w: f32,
55 pub node_h: f32,
56 pub collapsed_h: f32,
57 pub col_gap: f32,
58 pub row_gap: f32,
59 pub origin: Point,
60}
61
62impl Default for LayoutParams {
63 fn default() -> Self {
64 Self {
65 node_w: 160.0,
66 node_h: 56.0,
67 collapsed_h: 22.0,
68 col_gap: 64.0,
69 row_gap: 20.0,
70 origin: Point::new(16.0, 16.0),
71 }
72 }
73}
74
75/// Computa el layout del grafo de intenciones.
76pub fn layout(graph: &SessionGraph, p: &LayoutParams) -> CanvasPlan {
77 let cmds = graph.commands();
78 let mut edges = Vec::new();
79 // Profundidad de cada comando por su id (los comandos sólo refieren
80 // resultados previos, así que recorrer en orden de id basta).
81 let mut depth: Vec<(u32, usize)> = Vec::with_capacity(cmds.len());
82
83 for c in cmds {
84 let refs = Intention::parse(&c.intention).refs();
85 let mut d = 0usize;
86 for r in refs {
87 if let Some(producer) = graph.resolve(r) {
88 edges.push(Edge {
89 from_command: producer.id,
90 to_command: c.id,
91 buffer_id: producer.output_buffer,
92 });
93 let pd = depth
94 .iter()
95 .find(|(id, _)| *id == producer.id)
96 .map(|(_, d)| *d)
97 .unwrap_or(0);
98 d = d.max(pd + 1);
99 }
100 }
101 depth.push((c.id, d));
102 }
103
104 // Posiciona: columna = profundidad, fila = orden de llegada en la columna.
105 let mut rows_in_col: Vec<usize> = Vec::new();
106 let mut nodes = Vec::with_capacity(cmds.len());
107 for (c, &(_, col)) in cmds.iter().zip(&depth) {
108 while rows_in_col.len() <= col {
109 rows_in_col.push(0);
110 }
111 let row = rows_in_col[col];
112 rows_in_col[col] += 1;
113
114 let h = if c.collapsed { p.collapsed_h } else { p.node_h };
115 let x = p.origin.x + col as f32 * (p.node_w + p.col_gap);
116 let y = p.origin.y + row as f32 * (p.node_h + p.row_gap);
117 nodes.push(NodeBox {
118 command_id: c.id,
119 label: c.intention.clone(),
120 status: c.status,
121 collapsed: c.collapsed,
122 column: col,
123 rect: Rect::new(x, y, p.node_w, h),
124 });
125 }
126 CanvasPlan { nodes, edges }
127}
128
129/// Color de borde según el estado del nodo.
130fn status_color(s: NodeStatus) -> Color {
131 match s {
132 NodeStatus::Running => Color::from_hex(0xe0b341), // ámbar
133 NodeStatus::Ok => Color::from_hex(0x4caf6a), // verde
134 NodeStatus::Failed => Color::from_hex(0xd0463b), // rojo
135 }
136}
137
138/// Dibuja el plan contra un `Canvas`: aristas primero (al fondo), luego
139/// las cajas de comando. El texto lo dibuja el caller si quiere control
140/// de truncado/fuente.
141pub fn paint(plan: &CanvasPlan, canvas: &mut dyn Canvas) {
142 let edge_stroke = StrokeStyle::new(1.5, Color::from_hex(0x6b7280));
143 for e in &plan.edges {
144 let (Some(a), Some(b)) = (plan.node(e.from_command), plan.node(e.to_command))
145 else {
146 continue;
147 };
148 let from = Point::new(a.rect.right(), a.rect.y + a.rect.h / 2.0);
149 let to = Point::new(b.rect.x, b.rect.y + b.rect.h / 2.0);
150 canvas.stroke_line(from, to, edge_stroke);
151 }
152 for n in &plan.nodes {
153 canvas.fill_rect(n.rect, Color::from_hex(0x1c2128));
154 canvas.stroke_rect(n.rect, StrokeStyle::new(2.0, status_color(n.status)));
155 }
156}
157
158#[cfg(test)]
159mod tests {
160 use super::*;
161
162 /// Sesión: c1 produce %p1; c2 lo consume.
163 fn chained_session() -> SessionGraph {
164 let mut g = SessionGraph::new();
165 let c1 = g.record("cat data.json");
166 g.complete(c1, true, 2400); // → %p1
167 let _c2 = g.record("sort | %p1");
168 g
169 }
170
171 #[test]
172 fn layout_places_dependent_in_a_later_column() {
173 let g = chained_session();
174 let plan = layout(&g, &LayoutParams::default());
175 assert_eq!(plan.nodes.len(), 2);
176 let c1 = plan.node(1).unwrap();
177 let c2 = plan.node(2).unwrap();
178 assert_eq!(c1.column, 0);
179 assert_eq!(c2.column, 1, "el que consume %p1 va una columna después");
180 assert!(c2.rect.x > c1.rect.x);
181 }
182
183 #[test]
184 fn layout_creates_an_edge_for_the_buffer_flow() {
185 let g = chained_session();
186 let plan = layout(&g, &LayoutParams::default());
187 assert_eq!(plan.edges.len(), 1);
188 assert_eq!(plan.edges[0].from_command, 1);
189 assert_eq!(plan.edges[0].to_command, 2);
190 assert_eq!(plan.edges[0].buffer_id, Some(1));
191 }
192
193 #[test]
194 fn independent_commands_share_column_zero() {
195 let mut g = SessionGraph::new();
196 g.record("ls");
197 g.record("pwd");
198 let plan = layout(&g, &LayoutParams::default());
199 assert!(plan.nodes.iter().all(|n| n.column == 0));
200 assert!(plan.edges.is_empty());
201 // Apiladas en filas distintas.
202 assert_ne!(plan.nodes[0].rect.y, plan.nodes[1].rect.y);
203 }
204
205 #[test]
206 fn paint_emits_commands_to_a_recorder() {
207 use pineal_render::{PlanRecorder, RenderCmd};
208 let g = chained_session();
209 let plan = layout(&g, &LayoutParams::default());
210 let mut rec = PlanRecorder::new();
211 paint(&plan, &mut rec);
212 let cmds = rec.into_plan().cmds;
213 // 1 línea (la arista) + 2 fill + 2 stroke (las cajas).
214 assert!(cmds.iter().any(|c| matches!(c, RenderCmd::StrokeLine { .. })));
215 assert_eq!(
216 cmds.iter().filter(|c| matches!(c, RenderCmd::FillRect { .. })).count(),
217 2
218 );
219 }
220}