Skip to main content

batuta/tui/graph/
rendering.rs

1//! Graph rendering for TUI
2//!
3//! Contains `RenderMode`, `RenderedGraph`, and `GraphRenderer`.
4
5use super::graph_core::Graph;
6use super::types::{Node, Position};
7
8// ============================================================================
9// GRAPH-004: TUI Rendering
10// ============================================================================
11
12/// Render mode for terminal compatibility
13#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
14pub enum RenderMode {
15    /// Unicode with colors (default)
16    #[default]
17    Unicode,
18    /// ASCII fallback for legacy terminals (per peer review #5)
19    Ascii,
20    /// Plain text without colors
21    Plain,
22}
23
24/// Rendered graph as string buffer
25#[derive(Debug, Clone)]
26pub struct RenderedGraph {
27    /// Width in characters
28    pub width: usize,
29    /// Height in characters
30    pub height: usize,
31    /// Character buffer
32    pub buffer: Vec<Vec<char>>,
33    /// Color buffer (ANSI codes per cell)
34    pub colors: Vec<Vec<Option<&'static str>>>,
35}
36
37impl RenderedGraph {
38    /// Create new render buffer
39    #[must_use]
40    pub fn new(width: usize, height: usize) -> Self {
41        Self {
42            width,
43            height,
44            buffer: vec![vec![' '; width]; height],
45            colors: vec![vec![None; width]; height],
46        }
47    }
48
49    /// Set character at position
50    pub fn set(&mut self, x: usize, y: usize, ch: char, color: Option<&'static str>) {
51        if x < self.width && y < self.height {
52            self.buffer[y][x] = ch;
53            self.colors[y][x] = color;
54        }
55    }
56
57    /// Render to string with ANSI colors
58    #[must_use]
59    pub fn to_string_colored(&self) -> String {
60        let mut result = String::new();
61        for y in 0..self.height {
62            for x in 0..self.width {
63                if let Some(color) = self.colors[y][x] {
64                    result.push_str(color);
65                    result.push(self.buffer[y][x]);
66                    result.push_str("\x1b[0m");
67                } else {
68                    result.push(self.buffer[y][x]);
69                }
70            }
71            result.push('\n');
72        }
73        result
74    }
75
76    /// Render to plain string (no colors)
77    #[must_use]
78    pub fn to_string_plain(&self) -> String {
79        self.buffer.iter().map(|row| row.iter().collect::<String>()).collect::<Vec<_>>().join("\n")
80    }
81}
82
83/// Graph renderer
84pub struct GraphRenderer {
85    /// Render mode
86    pub mode: RenderMode,
87    /// Show labels
88    pub show_labels: bool,
89    /// Show edges
90    pub show_edges: bool,
91}
92
93impl Default for GraphRenderer {
94    fn default() -> Self {
95        Self { mode: RenderMode::Unicode, show_labels: true, show_edges: true }
96    }
97}
98
99impl GraphRenderer {
100    /// Create new renderer
101    #[must_use]
102    pub fn new() -> Self {
103        Self::default()
104    }
105
106    /// Set render mode
107    #[must_use]
108    pub fn with_mode(mut self, mode: RenderMode) -> Self {
109        self.mode = mode;
110        self
111    }
112
113    /// Render graph to buffer
114    pub fn render<N, E>(&self, graph: &Graph<N, E>, width: usize, height: usize) -> RenderedGraph {
115        let mut output = RenderedGraph::new(width, height);
116
117        // Draw edges first (underneath nodes)
118        if self.show_edges {
119            for edge in graph.edges() {
120                if let (Some(from), Some(to)) =
121                    (graph.get_node(&edge.from), graph.get_node(&edge.to))
122                {
123                    self.draw_edge(&mut output, &from.position, &to.position, width, height);
124                }
125            }
126        }
127
128        // Draw nodes
129        for node in graph.nodes() {
130            self.draw_node(&mut output, node, width, height);
131        }
132
133        output
134    }
135
136    fn draw_node<N>(
137        &self,
138        output: &mut RenderedGraph,
139        node: &Node<N>,
140        width: usize,
141        height: usize,
142    ) {
143        let x = (node.position.x / 80.0 * width as f32) as usize;
144        let y = (node.position.y / 24.0 * height as f32) as usize;
145
146        if x < width && y < height {
147            let ch = match self.mode {
148                RenderMode::Unicode => node.status.shape().unicode(),
149                RenderMode::Ascii | RenderMode::Plain => node.status.shape().ascii(),
150            };
151
152            let color = match self.mode {
153                RenderMode::Unicode | RenderMode::Ascii => Some(node.status.color_code()),
154                RenderMode::Plain => None,
155            };
156
157            output.set(x, y, ch, color);
158
159            // Draw label if enabled
160            if self.show_labels {
161                if let Some(ref label) = node.label {
162                    let label_start = x.saturating_add(2);
163                    for (i, c) in label.chars().take(10).enumerate() {
164                        if label_start + i < width {
165                            output.set(label_start + i, y, c, color);
166                        }
167                    }
168                }
169            }
170        }
171    }
172
173    fn draw_edge(
174        &self,
175        output: &mut RenderedGraph,
176        from: &Position,
177        to: &Position,
178        width: usize,
179        height: usize,
180    ) {
181        let x1 = (from.x / 80.0 * width as f32) as i32;
182        let y1 = (from.y / 24.0 * height as f32) as i32;
183        let x2 = (to.x / 80.0 * width as f32) as i32;
184        let y2 = (to.y / 24.0 * height as f32) as i32;
185
186        // Bresenham's line algorithm
187        let dx = (x2 - x1).abs();
188        let dy = (y2 - y1).abs();
189        let sx = if x1 < x2 { 1 } else { -1 };
190        let sy = if y1 < y2 { 1 } else { -1 };
191        let mut err = dx - dy;
192
193        let mut x = x1;
194        let mut y = y1;
195
196        let edge_char = match self.mode {
197            RenderMode::Unicode => 'ยท',
198            RenderMode::Ascii | RenderMode::Plain => '.',
199        };
200
201        while x != x2 || y != y2 {
202            if x >= 0 && x < width as i32 && y >= 0 && y < height as i32 {
203                // Don't overwrite nodes
204                if output.buffer[y as usize][x as usize] == ' ' {
205                    output.set(x as usize, y as usize, edge_char, Some("\x1b[90m"));
206                }
207            }
208
209            let e2 = 2 * err;
210            if e2 > -dy {
211                err -= dy;
212                x += sx;
213            }
214            if e2 < dx {
215                err += dx;
216                y += sy;
217            }
218        }
219    }
220}
221
222#[cfg(test)]
223mod tests {
224    use super::*;
225
226    #[test]
227    fn test_render_mode_default() {
228        assert_eq!(RenderMode::default(), RenderMode::Unicode);
229    }
230
231    #[test]
232    fn test_render_mode_equality() {
233        assert_eq!(RenderMode::Unicode, RenderMode::Unicode);
234        assert_eq!(RenderMode::Ascii, RenderMode::Ascii);
235        assert_eq!(RenderMode::Plain, RenderMode::Plain);
236        assert_ne!(RenderMode::Unicode, RenderMode::Ascii);
237    }
238
239    #[test]
240    fn test_rendered_graph_new() {
241        let graph = RenderedGraph::new(80, 24);
242        assert_eq!(graph.width, 80);
243        assert_eq!(graph.height, 24);
244        assert_eq!(graph.buffer.len(), 24);
245        assert_eq!(graph.buffer[0].len(), 80);
246        assert_eq!(graph.colors.len(), 24);
247    }
248
249    #[test]
250    fn test_rendered_graph_set() {
251        let mut graph = RenderedGraph::new(10, 10);
252        graph.set(5, 5, 'X', Some("\x1b[32m"));
253        assert_eq!(graph.buffer[5][5], 'X');
254        assert_eq!(graph.colors[5][5], Some("\x1b[32m"));
255    }
256
257    #[test]
258    fn test_rendered_graph_set_out_of_bounds() {
259        let mut graph = RenderedGraph::new(10, 10);
260        graph.set(15, 15, 'X', None);
261        // Should not crash, buffer unchanged
262        assert_eq!(graph.buffer[0][0], ' ');
263    }
264
265    #[test]
266    fn test_rendered_graph_to_string_plain() {
267        let mut graph = RenderedGraph::new(5, 3);
268        graph.set(2, 1, '*', None);
269        let output = graph.to_string_plain();
270        assert!(output.contains('*'));
271    }
272
273    #[test]
274    fn test_rendered_graph_to_string_colored() {
275        let mut graph = RenderedGraph::new(5, 3);
276        graph.set(2, 1, '*', Some("\x1b[32m"));
277        let output = graph.to_string_colored();
278        assert!(output.contains("\x1b[32m"));
279        assert!(output.contains("\x1b[0m"));
280    }
281
282    #[test]
283    fn test_graph_renderer_default() {
284        let renderer = GraphRenderer::default();
285        assert_eq!(renderer.mode, RenderMode::Unicode);
286        assert!(renderer.show_labels);
287        assert!(renderer.show_edges);
288    }
289
290    #[test]
291    fn test_graph_renderer_new() {
292        let renderer = GraphRenderer::new();
293        assert_eq!(renderer.mode, RenderMode::Unicode);
294    }
295
296    #[test]
297    fn test_graph_renderer_with_mode() {
298        let renderer = GraphRenderer::new().with_mode(RenderMode::Ascii);
299        assert_eq!(renderer.mode, RenderMode::Ascii);
300    }
301
302    #[test]
303    fn test_graph_renderer_render_empty_graph() {
304        let graph: Graph<(), ()> = Graph::new();
305        let renderer = GraphRenderer::new();
306        let output = renderer.render(&graph, 40, 12);
307        assert_eq!(output.width, 40);
308        assert_eq!(output.height, 12);
309    }
310}