Skip to main content

matrixcode_tui/workflow/
dag.rs

1//! Unicode DAG Renderer
2//!
3//! Renders workflow DAG using Unicode box-drawing characters
4
5use crate::workflow::types::{NodeVisualStatus, WorkflowViewState, node_type_icon};
6use matrixcode_core::workflow::NodeType;
7use ratatui::{
8    buffer::Buffer,
9    layout::Rect,
10    style::{Color, Style},
11    text::Line,
12    widgets::Widget,
13};
14
15/// DAG rendering constants
16const NODE_WIDTH: u16 = 16;
17const NODE_HEIGHT: u16 = 5;
18const SPACING_X: u16 = 2;
19const SPACING_Y: u16 = 1;
20
21/// DAG Widget for rendering workflow graph
22pub struct DagWidget<'a> {
23    state: &'a WorkflowViewState,
24}
25
26impl<'a> DagWidget<'a> {
27    pub fn new(state: &'a WorkflowViewState) -> Self {
28        Self { state }
29    }
30}
31
32impl<'a> Widget for DagWidget<'a> {
33    fn render(self, area: Rect, buf: &mut Buffer) {
34        if self.state.workflow_def.is_none() {
35            // No workflow loaded
36            let text = Line::from("No workflow running");
37            text.render(area, buf);
38            return;
39        }
40
41        // Render nodes and edges
42        self.render_dag(area, buf);
43    }
44}
45
46impl<'a> DagWidget<'a> {
47    fn render_dag(&self, area: Rect, buf: &mut Buffer) {
48        let def = self.state.workflow_def.as_ref().unwrap();
49
50        // Center the DAG in available area
51        let total_height = (self.state.layout.height as u16) * (NODE_HEIGHT + SPACING_Y);
52        let total_width = (self.state.layout.width as u16) * (NODE_WIDTH + SPACING_X);
53
54        let start_y = if area.height > total_height {
55            (area.height - total_height) / 2
56        } else {
57            0
58        };
59
60        let start_x = if area.width > total_width + 2 {
61            (area.width - total_width) / 2
62        } else {
63            1
64        };
65
66        // Render each node
67        for node in &def.nodes {
68            let pos = self.state.layout.node_positions.get(&node.id);
69            if let Some((row, col)) = pos {
70                let x = area.x + start_x + (*col as u16) * (NODE_WIDTH + SPACING_X);
71                let y = start_y + (*row as u16) * (NODE_HEIGHT + SPACING_Y);
72
73                // Ensure within bounds
74                if x < area.right() && y < area.bottom() {
75                    let node_rect = Rect::new(x, y, NODE_WIDTH, NODE_HEIGHT);
76                    self.render_node(&node.id, &node.name, &node.node_type, node_rect, buf);
77                }
78            }
79        }
80
81        // Render edges
82        for edge in &self.state.layout.edges {
83            self.render_edge(&edge.from, &edge.to, area, buf, start_y, start_x);
84        }
85
86        // Render progress info at bottom
87        let (completed, total) = self.state.progress();
88        let progress_text = format!("Progress: {}/{} nodes", completed, total);
89        let progress_y = area.bottom().saturating_sub(1);
90        if progress_y > area.y {
91            buf.set_string(
92                area.x,
93                progress_y,
94                progress_text,
95                Style::default().fg(Color::Gray),
96            );
97        }
98    }
99
100    fn render_node(
101        &self,
102        id: &str,
103        name: &str,
104        node_type: &NodeType,
105        rect: Rect,
106        buf: &mut Buffer,
107    ) {
108        let status = self.state.get_node_status(id);
109
110        // Determine colors based on status
111        let (border_color, text_color) = match &status {
112            NodeVisualStatus::Pending => (Color::Gray, Color::Gray),
113            NodeVisualStatus::Running => (Color::Yellow, Color::Yellow),
114            NodeVisualStatus::Completed => (Color::Green, Color::Green),
115            NodeVisualStatus::Failed { .. } => (Color::Red, Color::Red),
116            NodeVisualStatus::Skipped => (Color::Blue, Color::Blue),
117        };
118
119        // Draw box borders
120        let box_chars = if matches!(status, NodeVisualStatus::Running) {
121            ("╔", "╗", "╚", "╝", "║", "═")
122        } else {
123            ("┌", "┐", "└", "┘", "│", "─")
124        };
125
126        let width = rect.width.saturating_sub(1);
127        let height = rect.height;
128
129        // Top border
130        buf.set_string(
131            rect.x,
132            rect.y,
133            box_chars.0,
134            Style::default().fg(border_color),
135        );
136        for x in rect.x + 1..rect.x + width {
137            buf.set_string(x, rect.y, box_chars.5, Style::default().fg(border_color));
138        }
139        buf.set_string(
140            rect.x + width,
141            rect.y,
142            box_chars.1,
143            Style::default().fg(border_color),
144        );
145
146        // Content lines (support dynamic height)
147        let icon = node_type_icon(node_type);
148        let status_icon = status.icon();
149        let spinner = if matches!(status, NodeVisualStatus::Running) {
150            self.state.spinner_char().to_string()
151        } else {
152            " ".to_string()
153        };
154
155        // Line 1: Icon + Status + Spinner
156        let line1 = format!("{} {}{}", icon, status_icon, spinner);
157        let display_line1 = truncate(&line1, width.saturating_sub(2) as usize);
158        buf.set_string(
159            rect.x + 1,
160            rect.y + 1,
161            &display_line1,
162            Style::default().fg(text_color),
163        );
164        buf.set_string(
165            rect.x,
166            rect.y + 1,
167            box_chars.4,
168            Style::default().fg(border_color),
169        );
170        buf.set_string(
171            rect.x + width,
172            rect.y + 1,
173            box_chars.4,
174            Style::default().fg(border_color),
175        );
176
177        // Line 2: Node name
178        let display_name = truncate(name, width.saturating_sub(2) as usize);
179        buf.set_string(
180            rect.x + 1,
181            rect.y + 2,
182            &display_name,
183            Style::default().fg(Color::White),
184        );
185        buf.set_string(
186            rect.x,
187            rect.y + 2,
188            box_chars.4,
189            Style::default().fg(border_color),
190        );
191        buf.set_string(
192            rect.x + width,
193            rect.y + 2,
194            box_chars.4,
195            Style::default().fg(border_color),
196        );
197
198        // Additional lines for larger boxes (empty with borders)
199        for line in 3..height.saturating_sub(1) {
200            buf.set_string(
201                rect.x,
202                rect.y + line,
203                box_chars.4,
204                Style::default().fg(border_color),
205            );
206            buf.set_string(
207                rect.x + width,
208                rect.y + line,
209                box_chars.4,
210                Style::default().fg(border_color),
211            );
212        }
213
214        // Bottom border
215        let bottom_y = rect.y + height.saturating_sub(1);
216        buf.set_string(
217            rect.x,
218            bottom_y,
219            box_chars.2,
220            Style::default().fg(border_color),
221        );
222        for x in rect.x + 1..rect.x + width {
223            buf.set_string(x, bottom_y, box_chars.5, Style::default().fg(border_color));
224        }
225        buf.set_string(
226            rect.x + width,
227            bottom_y,
228            box_chars.3,
229            Style::default().fg(border_color),
230        );
231    }
232
233    fn render_edge(
234        &self,
235        from_id: &str,
236        to_id: &str,
237        area: Rect,
238        buf: &mut Buffer,
239        start_y: u16,
240        start_x: u16,
241    ) {
242        let from_pos = self.state.layout.node_positions.get(from_id);
243        let to_pos = self.state.layout.node_positions.get(to_id);
244
245        if let (Some((from_row, from_col)), Some((to_row, to_col))) = (from_pos, to_pos) {
246            // From node bottom center
247            let from_x =
248                area.x + start_x + (*from_col as u16) * (NODE_WIDTH + SPACING_X) + NODE_WIDTH / 2;
249            let from_y = start_y + (*from_row as u16) * (NODE_HEIGHT + SPACING_Y) + NODE_HEIGHT;
250
251            // To node top center
252            let to_x =
253                area.x + start_x + (*to_col as u16) * (NODE_WIDTH + SPACING_X) + NODE_WIDTH / 2;
254            let to_y = start_y + (*to_row as u16) * (NODE_HEIGHT + SPACING_Y);
255
256            // Draw edge based on relative positions
257            if from_col == to_col {
258                // Same column: direct vertical line
259                if from_y < area.bottom() {
260                    buf.set_string(from_x, from_y, "│", Style::default().fg(Color::Gray));
261                }
262                if from_y + 1 < area.bottom() && from_y < to_y {
263                    buf.set_string(from_x, from_y + 1, "▼", Style::default().fg(Color::Gray));
264                }
265            } else {
266                // Different columns: need horizontal segment
267                // Draw from bottom of source node going down
268                if from_y < area.bottom() {
269                    buf.set_string(from_x, from_y, "│", Style::default().fg(Color::Gray));
270                }
271
272                // Draw horizontal line at the middle point
273                let mid_y = from_y + 1;
274                if mid_y < area.bottom() && mid_y < to_y {
275                    // Draw horizontal line from from_x to to_x
276                    let (start_x, end_x) = if from_x < to_x {
277                        (from_x, to_x)
278                    } else {
279                        (to_x, from_x)
280                    };
281                    for x in start_x..=end_x {
282                        if x < area.right() {
283                            buf.set_string(x, mid_y, "─", Style::default().fg(Color::Gray));
284                        }
285                    }
286                    // Corners
287                    if from_x < to_x {
288                        if from_x < area.right() {
289                            buf.set_string(from_x, mid_y, "┐", Style::default().fg(Color::Gray));
290                        }
291                        if to_x < area.right() {
292                            buf.set_string(to_x, mid_y, "┌", Style::default().fg(Color::Gray));
293                        }
294                    } else {
295                        if from_x < area.right() {
296                            buf.set_string(from_x, mid_y, "┘", Style::default().fg(Color::Gray));
297                        }
298                        if to_x < area.right() {
299                            buf.set_string(to_x, mid_y, "└", Style::default().fg(Color::Gray));
300                        }
301                    }
302                }
303
304                // Draw vertical line to target node
305                for y in (mid_y + 1)..to_y.min(area.bottom()) {
306                    buf.set_string(to_x, y, "│", Style::default().fg(Color::Gray));
307                }
308
309                // Arrow at target
310                if to_y < area.bottom() {
311                    buf.set_string(to_x, to_y, "▼", Style::default().fg(Color::Gray));
312                }
313            }
314        }
315    }
316}
317
318/// Truncate string to fit width
319fn truncate(s: &str, max_len: usize) -> String {
320    if s.chars().count() <= max_len {
321        s.to_string()
322    } else {
323        s.chars()
324            .take(max_len.saturating_sub(1))
325            .collect::<String>()
326            + "…"
327    }
328}
329
330/// Render compact progress view
331pub fn render_progress(state: &WorkflowViewState, area: Rect, buf: &mut Buffer) {
332    if state.workflow_def.is_none() {
333        return;
334    }
335
336    let def = state.workflow_def.as_ref().unwrap();
337
338    // Header
339    let workflow_name = def.name.as_str();
340    let (completed, total) = state.progress();
341    let status_text = if let Some(ctx) = &state.context {
342        match ctx.status {
343            matrixcode_core::workflow::WorkflowStatus::Running => "▶ running",
344            matrixcode_core::workflow::WorkflowStatus::Completed => "✓ completed",
345            matrixcode_core::workflow::WorkflowStatus::Failed => "✗ failed",
346            matrixcode_core::workflow::WorkflowStatus::Paused => "⏸ paused",
347            _ => "○ pending",
348        }
349    } else {
350        "○ pending"
351    };
352
353    // Title line
354    let title = format!("{} [{}]", workflow_name, status_text);
355    buf.set_string(
356        area.x,
357        area.y,
358        title,
359        Style::default()
360            .fg(Color::White)
361            .add_modifier(ratatui::style::Modifier::BOLD),
362    );
363
364    // Progress bar
365    let bar_width = area.width.saturating_sub(22);
366    let filled = if total > 0 {
367        (bar_width as usize * completed) / total
368    } else {
369        0
370    };
371
372    let bar_y = area.y + 1;
373    buf.set_string(area.x, bar_y, "[", Style::default().fg(Color::Gray));
374    for i in 0..bar_width as usize {
375        let ch = if i < filled { "█" } else { "░" };
376        let color = if i < filled {
377            Color::Green
378        } else {
379            Color::Gray
380        };
381        buf.set_string(area.x + 1 + i as u16, bar_y, ch, Style::default().fg(color));
382    }
383    buf.set_string(
384        area.x + 1 + bar_width,
385        bar_y,
386        "]",
387        Style::default().fg(Color::Gray),
388    );
389    buf.set_string(
390        area.x + 2 + bar_width,
391        bar_y,
392        format!(
393            " {}%",
394            if total > 0 {
395                completed * 100 / total
396            } else {
397                0
398            }
399        ),
400        Style::default().fg(Color::Gray),
401    );
402
403    // Node status strip
404    let strip_y = area.y + 2;
405    let mut x = area.x;
406    for node in &def.nodes {
407        if x >= area.right() {
408            break;
409        }
410        let status = state.get_node_status(&node.id);
411        let icon = node_type_icon(&node.node_type);
412        let status_icon = status.icon();
413        let color = match status.color() {
414            "gray" => Color::Gray,
415            "yellow" => Color::Yellow,
416            "green" => Color::Green,
417            "red" => Color::Red,
418            "blue" => Color::Blue,
419            _ => Color::Reset,
420        };
421        buf.set_string(
422            x,
423            strip_y,
424            format!("{}{}", icon, status_icon),
425            Style::default().fg(color),
426        );
427        x += 4;
428    }
429}