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 ratatui::{
6    buffer::Buffer,
7    layout::Rect,
8    style::{Style, Color},
9    text::Line,
10    widgets::Widget,
11};
12use matrixcode_core::workflow::NodeType;
13use crate::workflow::types::{WorkflowViewState, node_type_icon, NodeVisualStatus};
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(area.x, progress_y, progress_text, Style::default().fg(Color::Gray));
92        }
93    }
94
95    fn render_node(&self, id: &str, name: &str, node_type: &NodeType, rect: Rect, buf: &mut Buffer) {
96        let status = self.state.get_node_status(id);
97
98        // Determine colors based on status
99        let (border_color, text_color) = match &status {
100            NodeVisualStatus::Pending => (Color::Gray, Color::Gray),
101            NodeVisualStatus::Running => (Color::Yellow, Color::Yellow),
102            NodeVisualStatus::Completed => (Color::Green, Color::Green),
103            NodeVisualStatus::Failed { .. } => (Color::Red, Color::Red),
104            NodeVisualStatus::Skipped => (Color::Blue, Color::Blue),
105        };
106
107        // Draw box borders
108        let box_chars = if matches!(status, NodeVisualStatus::Running) {
109            ("╔", "╗", "╚", "╝", "║", "═")
110        } else {
111            ("┌", "┐", "└", "┘", "│", "─")
112        };
113
114        let width = rect.width.saturating_sub(1);
115        let height = rect.height;
116
117        // Top border
118        buf.set_string(rect.x, rect.y, box_chars.0, Style::default().fg(border_color));
119        for x in rect.x + 1..rect.x + width {
120            buf.set_string(x, rect.y, box_chars.5, Style::default().fg(border_color));
121        }
122        buf.set_string(rect.x + width, rect.y, box_chars.1, Style::default().fg(border_color));
123
124        // Content lines (support dynamic height)
125        let icon = node_type_icon(node_type);
126        let status_icon = status.icon();
127        let spinner = if matches!(status, NodeVisualStatus::Running) {
128            self.state.spinner_char().to_string()
129        } else {
130            " ".to_string()
131        };
132
133        // Line 1: Icon + Status + Spinner
134        let line1 = format!("{} {}{}", icon, status_icon, spinner);
135        let display_line1 = truncate(&line1, width.saturating_sub(2) as usize);
136        buf.set_string(rect.x + 1, rect.y + 1, &display_line1, Style::default().fg(text_color));
137        buf.set_string(rect.x, rect.y + 1, box_chars.4, Style::default().fg(border_color));
138        buf.set_string(rect.x + width, rect.y + 1, box_chars.4, Style::default().fg(border_color));
139
140        // Line 2: Node name
141        let display_name = truncate(name, width.saturating_sub(2) as usize);
142        buf.set_string(rect.x + 1, rect.y + 2, &display_name, Style::default().fg(Color::White));
143        buf.set_string(rect.x, rect.y + 2, box_chars.4, Style::default().fg(border_color));
144        buf.set_string(rect.x + width, rect.y + 2, box_chars.4, Style::default().fg(border_color));
145
146        // Additional lines for larger boxes (empty with borders)
147        for line in 3..height.saturating_sub(1) {
148            buf.set_string(rect.x, rect.y + line, box_chars.4, Style::default().fg(border_color));
149            buf.set_string(rect.x + width, rect.y + line, box_chars.4, Style::default().fg(border_color));
150        }
151
152        // Bottom border
153        let bottom_y = rect.y + height.saturating_sub(1);
154        buf.set_string(rect.x, bottom_y, box_chars.2, Style::default().fg(border_color));
155        for x in rect.x + 1..rect.x + width {
156            buf.set_string(x, bottom_y, box_chars.5, Style::default().fg(border_color));
157        }
158        buf.set_string(rect.x + width, bottom_y, box_chars.3, Style::default().fg(border_color));
159    }
160
161    fn render_edge(&self, from_id: &str, to_id: &str, area: Rect, buf: &mut Buffer, start_y: u16, start_x: u16) {
162        let from_pos = self.state.layout.node_positions.get(from_id);
163        let to_pos = self.state.layout.node_positions.get(to_id);
164
165        if let (Some((from_row, from_col)), Some((to_row, to_col))) = (from_pos, to_pos) {
166            // From node bottom center
167            let from_x = area.x + start_x + (*from_col as u16) * (NODE_WIDTH + SPACING_X) + NODE_WIDTH / 2;
168            let from_y = start_y + (*from_row as u16) * (NODE_HEIGHT + SPACING_Y) + NODE_HEIGHT;
169
170            // To node top center
171            let to_x = area.x + start_x + (*to_col as u16) * (NODE_WIDTH + SPACING_X) + NODE_WIDTH / 2;
172            let to_y = start_y + (*to_row as u16) * (NODE_HEIGHT + SPACING_Y);
173
174            // Draw edge based on relative positions
175            if from_col == to_col {
176                // Same column: direct vertical line
177                if from_y < area.bottom() {
178                    buf.set_string(from_x, from_y, "│", Style::default().fg(Color::Gray));
179                }
180                if from_y + 1 < area.bottom() && from_y < to_y {
181                    buf.set_string(from_x, from_y + 1, "▼", Style::default().fg(Color::Gray));
182                }
183            } else {
184                // Different columns: need horizontal segment
185                // Draw from bottom of source node going down
186                if from_y < area.bottom() {
187                    buf.set_string(from_x, from_y, "│", Style::default().fg(Color::Gray));
188                }
189
190                // Draw horizontal line at the middle point
191                let mid_y = from_y + 1;
192                if mid_y < area.bottom() && mid_y < to_y {
193                    // Draw horizontal line from from_x to to_x
194                    let (start_x, end_x) = if from_x < to_x {
195                        (from_x, to_x)
196                    } else {
197                        (to_x, from_x)
198                    };
199                    for x in start_x..=end_x {
200                        if x < area.right() {
201                            buf.set_string(x, mid_y, "─", Style::default().fg(Color::Gray));
202                        }
203                    }
204                    // Corners
205                    if from_x < to_x {
206                        if from_x < area.right() {
207                            buf.set_string(from_x, mid_y, "┐", Style::default().fg(Color::Gray));
208                        }
209                        if to_x < area.right() {
210                            buf.set_string(to_x, mid_y, "┌", Style::default().fg(Color::Gray));
211                        }
212                    } else {
213                        if from_x < area.right() {
214                            buf.set_string(from_x, mid_y, "┘", Style::default().fg(Color::Gray));
215                        }
216                        if to_x < area.right() {
217                            buf.set_string(to_x, mid_y, "└", Style::default().fg(Color::Gray));
218                        }
219                    }
220                }
221
222                // Draw vertical line to target node
223                for y in (mid_y + 1)..to_y.min(area.bottom()) {
224                    buf.set_string(to_x, y, "│", Style::default().fg(Color::Gray));
225                }
226
227                // Arrow at target
228                if to_y < area.bottom() {
229                    buf.set_string(to_x, to_y, "▼", Style::default().fg(Color::Gray));
230                }
231            }
232        }
233    }
234}
235
236/// Truncate string to fit width
237fn truncate(s: &str, max_len: usize) -> String {
238    if s.chars().count() <= max_len {
239        s.to_string()
240    } else {
241        s.chars().take(max_len.saturating_sub(1)).collect::<String>() + "…"
242    }
243}
244
245/// Render compact progress view
246pub fn render_progress(state: &WorkflowViewState, area: Rect, buf: &mut Buffer) {
247    if state.workflow_def.is_none() {
248        return;
249    }
250
251    let def = state.workflow_def.as_ref().unwrap();
252
253    // Header
254    let workflow_name = def.name.as_str();
255    let (completed, total) = state.progress();
256    let status_text = if let Some(ctx) = &state.context {
257        match ctx.status {
258            matrixcode_core::workflow::WorkflowStatus::Running => "▶ running",
259            matrixcode_core::workflow::WorkflowStatus::Completed => "✓ completed",
260            matrixcode_core::workflow::WorkflowStatus::Failed => "✗ failed",
261            matrixcode_core::workflow::WorkflowStatus::Paused => "⏸ paused",
262            _ => "○ pending",
263        }
264    } else {
265        "○ pending"
266    };
267
268    // Title line
269    let title = format!("{} [{}]", workflow_name, status_text);
270    buf.set_string(area.x, area.y, title, Style::default().fg(Color::White).add_modifier(ratatui::style::Modifier::BOLD));
271
272    // Progress bar
273    let bar_width = area.width.saturating_sub(22);
274    let filled = if total > 0 {
275        (bar_width as usize * completed) / total
276    } else {
277        0
278    };
279
280    let bar_y = area.y + 1;
281    buf.set_string(area.x, bar_y, "[", Style::default().fg(Color::Gray));
282    for i in 0..bar_width as usize {
283        let ch = if i < filled { "█" } else { "░" };
284        let color = if i < filled { Color::Green } else { Color::Gray };
285        buf.set_string(area.x + 1 + i as u16, bar_y, ch, Style::default().fg(color));
286    }
287    buf.set_string(area.x + 1 + bar_width, bar_y, "]", Style::default().fg(Color::Gray));
288    buf.set_string(area.x + 2 + bar_width, bar_y, format!(" {}%", if total > 0 { completed * 100 / total } else { 0 }), Style::default().fg(Color::Gray));
289
290    // Node status strip
291    let strip_y = area.y + 2;
292    let mut x = area.x;
293    for node in &def.nodes {
294        if x >= area.right() {
295            break;
296        }
297        let status = state.get_node_status(&node.id);
298        let icon = node_type_icon(&node.node_type);
299        let status_icon = status.icon();
300        let color = match status.color() {
301            "gray" => Color::Gray,
302            "yellow" => Color::Yellow,
303            "green" => Color::Green,
304            "red" => Color::Red,
305            "blue" => Color::Blue,
306            _ => Color::Reset,
307        };
308        buf.set_string(x, strip_y, format!("{}{}", icon, status_icon), Style::default().fg(color));
309        x += 4;
310    }
311}