fusabi_tui/canvas/
graph.rs

1//! Graph widget for canvas-based visualization
2//!
3//! This module provides a complete graph visualization widget using
4//! ratatui's canvas for rendering nodes and edges as a directed graph.
5
6use super::bounds::calculate_bounds;
7use super::edge::render_edge;
8use super::node::render_node;
9use ratatui::{
10    buffer::Buffer,
11    layout::Rect,
12    style::Color,
13    widgets::{
14        canvas::{Canvas, Context},
15        Block, Widget,
16    },
17};
18use std::collections::HashMap;
19
20/// A node in the graph
21///
22/// Represents a single node with position, size, visual properties,
23/// and selection state.
24#[derive(Debug, Clone)]
25pub struct GraphNode {
26    /// Unique identifier for the node
27    pub id: String,
28    /// X coordinate of the node's top-left corner
29    pub x: f64,
30    /// Y coordinate of the node's top-left corner
31    pub y: f64,
32    /// Width of the node
33    pub width: f64,
34    /// Height of the node
35    pub height: f64,
36    /// Color of the node
37    pub color: Color,
38    /// Display label for the node
39    pub label: String,
40    /// Whether the node is currently selected
41    pub selected: bool,
42}
43
44impl GraphNode {
45    /// Create a new graph node
46    ///
47    /// # Arguments
48    ///
49    /// * `id` - Unique identifier
50    /// * `x` - X position
51    /// * `y` - Y position
52    /// * `label` - Display label
53    ///
54    /// # Returns
55    ///
56    /// A new GraphNode with default dimensions and color
57    pub fn new(id: impl Into<String>, x: f64, y: f64, label: impl Into<String>) -> Self {
58        Self {
59            id: id.into(),
60            x,
61            y,
62            width: 8.0,
63            height: 4.0,
64            color: Color::Blue,
65            label: label.into(),
66            selected: false,
67        }
68    }
69
70    /// Create a builder for the node
71    pub fn builder(id: impl Into<String>) -> GraphNodeBuilder {
72        GraphNodeBuilder::new(id)
73    }
74}
75
76/// Builder for GraphNode with fluent API
77#[derive(Debug)]
78pub struct GraphNodeBuilder {
79    id: String,
80    x: f64,
81    y: f64,
82    width: f64,
83    height: f64,
84    color: Color,
85    label: String,
86    selected: bool,
87}
88
89impl GraphNodeBuilder {
90    /// Create a new builder
91    pub fn new(id: impl Into<String>) -> Self {
92        let id = id.into();
93        Self {
94            label: id.clone(),
95            id,
96            x: 0.0,
97            y: 0.0,
98            width: 8.0,
99            height: 4.0,
100            color: Color::Blue,
101            selected: false,
102        }
103    }
104
105    /// Set position
106    pub fn position(mut self, x: f64, y: f64) -> Self {
107        self.x = x;
108        self.y = y;
109        self
110    }
111
112    /// Set size
113    pub fn size(mut self, width: f64, height: f64) -> Self {
114        self.width = width;
115        self.height = height;
116        self
117    }
118
119    /// Set color
120    pub fn color(mut self, color: Color) -> Self {
121        self.color = color;
122        self
123    }
124
125    /// Set label
126    pub fn label(mut self, label: impl Into<String>) -> Self {
127        self.label = label.into();
128        self
129    }
130
131    /// Set selected state
132    pub fn selected(mut self, selected: bool) -> Self {
133        self.selected = selected;
134        self
135    }
136
137    /// Build the node
138    pub fn build(self) -> GraphNode {
139        GraphNode {
140            id: self.id,
141            x: self.x,
142            y: self.y,
143            width: self.width,
144            height: self.height,
145            color: self.color,
146            label: self.label,
147            selected: self.selected,
148        }
149    }
150}
151
152/// An edge connecting two nodes
153///
154/// Represents a directed edge from one node to another with
155/// optional styling and labels.
156#[derive(Debug, Clone)]
157pub struct GraphEdge {
158    /// ID of the source node
159    pub from: String,
160    /// ID of the destination node
161    pub to: String,
162    /// Color of the edge
163    pub color: Color,
164    /// Optional label for the edge (e.g., weight, throughput)
165    pub label: Option<String>,
166}
167
168impl GraphEdge {
169    /// Create a new graph edge
170    ///
171    /// # Arguments
172    ///
173    /// * `from` - Source node ID
174    /// * `to` - Destination node ID
175    ///
176    /// # Returns
177    ///
178    /// A new GraphEdge with default color (Cyan)
179    pub fn new(from: impl Into<String>, to: impl Into<String>) -> Self {
180        Self {
181            from: from.into(),
182            to: to.into(),
183            color: Color::Cyan,
184            label: None,
185        }
186    }
187
188    /// Set the edge color
189    pub fn color(mut self, color: Color) -> Self {
190        self.color = color;
191        self
192    }
193
194    /// Set the edge label
195    pub fn label(mut self, label: impl Into<String>) -> Self {
196        self.label = Some(label.into());
197        self
198    }
199}
200
201/// Complete graph data structure
202///
203/// Contains all nodes and edges for rendering.
204#[derive(Debug, Clone, Default)]
205pub struct GraphData {
206    /// All nodes in the graph
207    pub nodes: Vec<GraphNode>,
208    /// All edges in the graph
209    pub edges: Vec<GraphEdge>,
210}
211
212impl GraphData {
213    /// Create a new empty graph
214    pub fn new() -> Self {
215        Self::default()
216    }
217
218    /// Create a graph with nodes and edges
219    pub fn with_data(nodes: Vec<GraphNode>, edges: Vec<GraphEdge>) -> Self {
220        Self { nodes, edges }
221    }
222
223    /// Add a node to the graph
224    pub fn add_node(&mut self, node: GraphNode) {
225        self.nodes.push(node);
226    }
227
228    /// Add an edge to the graph
229    pub fn add_edge(&mut self, edge: GraphEdge) {
230        self.edges.push(edge);
231    }
232
233    /// Find a node by ID
234    pub fn find_node(&self, id: &str) -> Option<&GraphNode> {
235        self.nodes.iter().find(|n| n.id == id)
236    }
237
238    /// Find a mutable node by ID
239    pub fn find_node_mut(&mut self, id: &str) -> Option<&mut GraphNode> {
240        self.nodes.iter_mut().find(|n| n.id == id)
241    }
242
243    /// Get all edges from a specific node
244    pub fn edges_from(&self, node_id: &str) -> Vec<&GraphEdge> {
245        self.edges.iter().filter(|e| e.from == node_id).collect()
246    }
247
248    /// Get all edges to a specific node
249    pub fn edges_to(&self, node_id: &str) -> Vec<&GraphEdge> {
250        self.edges.iter().filter(|e| e.to == node_id).collect()
251    }
252}
253
254/// Graph canvas widget
255///
256/// Renders a directed graph with nodes and edges on a canvas.
257/// Supports automatic bounds calculation and optional block wrapping.
258pub struct GraphCanvas<'a> {
259    graph: &'a GraphData,
260    block: Option<Block<'a>>,
261    x_bounds: Option<(f64, f64)>,
262    y_bounds: Option<(f64, f64)>,
263}
264
265impl<'a> GraphCanvas<'a> {
266    /// Create a new graph canvas widget
267    ///
268    /// # Arguments
269    ///
270    /// * `graph` - Graph data to render
271    ///
272    /// # Example
273    ///
274    /// ```
275    /// use fusabi_tui::canvas::graph::{GraphCanvas, GraphData, GraphNode, GraphEdge};
276    /// use ratatui::style::Color;
277    ///
278    /// let mut graph = GraphData::new();
279    /// graph.add_node(GraphNode::new("node1", 10.0, 20.0, "Node 1"));
280    /// graph.add_node(GraphNode::new("node2", 50.0, 20.0, "Node 2"));
281    /// graph.add_edge(GraphEdge::new("node1", "node2"));
282    ///
283    /// let widget = GraphCanvas::new(&graph);
284    /// ```
285    pub fn new(graph: &'a GraphData) -> Self {
286        Self {
287            graph,
288            block: None,
289            x_bounds: None,
290            y_bounds: None,
291        }
292    }
293
294    /// Set an optional block to wrap the canvas
295    pub fn block(mut self, block: Block<'a>) -> Self {
296        self.block = Some(block);
297        self
298    }
299
300    /// Set custom X bounds (overrides auto-calculation)
301    pub fn x_bounds(mut self, min: f64, max: f64) -> Self {
302        self.x_bounds = Some((min, max));
303        self
304    }
305
306    /// Set custom Y bounds (overrides auto-calculation)
307    pub fn y_bounds(mut self, min: f64, max: f64) -> Self {
308        self.y_bounds = Some((min, max));
309        self
310    }
311
312    /// Calculate or use provided bounds
313    fn get_bounds(&self) -> (f64, f64, f64, f64) {
314        let (auto_min_x, auto_max_x, auto_min_y, auto_max_y) = calculate_bounds(&self.graph.nodes);
315
316        let (min_x, max_x) = self.x_bounds.unwrap_or((auto_min_x, auto_max_x));
317        let (min_y, max_y) = self.y_bounds.unwrap_or((auto_min_y, auto_max_y));
318
319        (min_x, max_x, min_y, max_y)
320    }
321
322    /// Paint function for the canvas
323    #[allow(dead_code)]
324    fn paint(&self, ctx: &mut Context) {
325        // Build node lookup map for edge rendering
326        let node_map: HashMap<String, &GraphNode> =
327            self.graph.nodes.iter().map(|n| (n.id.clone(), n)).collect();
328
329        // Render edges first (so they appear behind nodes)
330        for edge in &self.graph.edges {
331            render_edge(ctx, edge, &node_map);
332        }
333
334        // Render nodes
335        for node in &self.graph.nodes {
336            render_node(ctx, node);
337        }
338    }
339}
340
341impl<'a> Widget for GraphCanvas<'a> {
342    fn render(self, area: Rect, buf: &mut Buffer) {
343        // Calculate bounds for the canvas
344        let (min_x, max_x, min_y, max_y) = self.get_bounds();
345
346        // Clone data needed for the paint closure
347        let nodes = self.graph.nodes.clone();
348        let edges = self.graph.edges.clone();
349
350        // Create the canvas widget
351        let canvas = Canvas::default()
352            .block(self.block.unwrap_or_default())
353            .x_bounds([min_x, max_x])
354            .y_bounds([min_y, max_y])
355            .paint(move |ctx| {
356                // Build node lookup map for edge rendering
357                let node_map: HashMap<String, &GraphNode> =
358                    nodes.iter().map(|n| (n.id.clone(), n)).collect();
359
360                // Render edges first (so they appear behind nodes)
361                for edge in &edges {
362                    render_edge(ctx, edge, &node_map);
363                }
364
365                // Render nodes
366                for node in &nodes {
367                    render_node(ctx, node);
368                }
369            });
370
371        // Render the canvas
372        canvas.render(area, buf);
373    }
374}
375
376#[cfg(test)]
377mod tests {
378    use super::*;
379
380    #[test]
381    fn test_graph_node_new() {
382        let node = GraphNode::new("node1", 10.0, 20.0, "Test Node");
383        assert_eq!(node.id, "node1");
384        assert_eq!(node.x, 10.0);
385        assert_eq!(node.y, 20.0);
386        assert_eq!(node.label, "Test Node");
387        assert_eq!(node.width, 8.0);
388        assert_eq!(node.height, 4.0);
389        assert!(!node.selected);
390    }
391
392    #[test]
393    fn test_graph_node_builder() {
394        let node = GraphNode::builder("node1")
395            .position(10.0, 20.0)
396            .size(12.0, 6.0)
397            .color(Color::Red)
398            .label("Custom Node")
399            .selected(true)
400            .build();
401
402        assert_eq!(node.id, "node1");
403        assert_eq!(node.x, 10.0);
404        assert_eq!(node.y, 20.0);
405        assert_eq!(node.width, 12.0);
406        assert_eq!(node.height, 6.0);
407        assert_eq!(node.color, Color::Red);
408        assert_eq!(node.label, "Custom Node");
409        assert!(node.selected);
410    }
411
412    #[test]
413    fn test_graph_edge_new() {
414        let edge = GraphEdge::new("node1", "node2");
415        assert_eq!(edge.from, "node1");
416        assert_eq!(edge.to, "node2");
417        assert_eq!(edge.color, Color::Cyan);
418        assert!(edge.label.is_none());
419    }
420
421    #[test]
422    fn test_graph_edge_builder() {
423        let edge = GraphEdge::new("node1", "node2")
424            .color(Color::Green)
425            .label("100 ev/s");
426
427        assert_eq!(edge.color, Color::Green);
428        assert_eq!(edge.label, Some("100 ev/s".to_string()));
429    }
430
431    #[test]
432    fn test_graph_data_new() {
433        let graph = GraphData::new();
434        assert!(graph.nodes.is_empty());
435        assert!(graph.edges.is_empty());
436    }
437
438    #[test]
439    fn test_graph_data_add() {
440        let mut graph = GraphData::new();
441        graph.add_node(GraphNode::new("node1", 10.0, 20.0, "Node 1"));
442        graph.add_edge(GraphEdge::new("node1", "node2"));
443
444        assert_eq!(graph.nodes.len(), 1);
445        assert_eq!(graph.edges.len(), 1);
446    }
447
448    #[test]
449    fn test_graph_data_find_node() {
450        let mut graph = GraphData::new();
451        graph.add_node(GraphNode::new("node1", 10.0, 20.0, "Node 1"));
452
453        let found = graph.find_node("node1");
454        assert!(found.is_some());
455        assert_eq!(found.unwrap().id, "node1");
456
457        let not_found = graph.find_node("node2");
458        assert!(not_found.is_none());
459    }
460
461    #[test]
462    fn test_graph_data_edges_from() {
463        let mut graph = GraphData::new();
464        graph.add_edge(GraphEdge::new("node1", "node2"));
465        graph.add_edge(GraphEdge::new("node1", "node3"));
466        graph.add_edge(GraphEdge::new("node2", "node3"));
467
468        let edges = graph.edges_from("node1");
469        assert_eq!(edges.len(), 2);
470    }
471
472    #[test]
473    fn test_graph_data_edges_to() {
474        let mut graph = GraphData::new();
475        graph.add_edge(GraphEdge::new("node1", "node3"));
476        graph.add_edge(GraphEdge::new("node2", "node3"));
477        graph.add_edge(GraphEdge::new("node3", "node4"));
478
479        let edges = graph.edges_to("node3");
480        assert_eq!(edges.len(), 2);
481    }
482
483    #[test]
484    fn test_graph_canvas_new() {
485        let graph = GraphData::new();
486        let canvas = GraphCanvas::new(&graph);
487
488        let (min_x, max_x, min_y, max_y) = canvas.get_bounds();
489        // Empty graph should have default bounds
490        assert_eq!(min_x, 0.0);
491        assert_eq!(max_x, 100.0);
492        assert_eq!(min_y, 0.0);
493        assert_eq!(max_y, 100.0);
494    }
495
496    #[test]
497    fn test_graph_canvas_custom_bounds() {
498        let graph = GraphData::new();
499        let canvas = GraphCanvas::new(&graph)
500            .x_bounds(-50.0, 50.0)
501            .y_bounds(-25.0, 25.0);
502
503        let (min_x, max_x, min_y, max_y) = canvas.get_bounds();
504        assert_eq!(min_x, -50.0);
505        assert_eq!(max_x, 50.0);
506        assert_eq!(min_y, -25.0);
507        assert_eq!(max_y, 25.0);
508    }
509}