1use 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
15const NODE_WIDTH: u16 = 16;
17const NODE_HEIGHT: u16 = 5;
18const SPACING_X: u16 = 2;
19const SPACING_Y: u16 = 1;
20
21pub 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 let text = Line::from("No workflow running");
37 text.render(area, buf);
38 return;
39 }
40
41 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 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 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 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 for edge in &self.state.layout.edges {
83 self.render_edge(&edge.from, &edge.to, area, buf, start_y, start_x);
84 }
85
86 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 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 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 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 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 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 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 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 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 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 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 if from_col == to_col {
176 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 if from_y < area.bottom() {
187 buf.set_string(from_x, from_y, "│", Style::default().fg(Color::Gray));
188 }
189
190 let mid_y = from_y + 1;
192 if mid_y < area.bottom() && mid_y < to_y {
193 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 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 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 if to_y < area.bottom() {
229 buf.set_string(to_x, to_y, "▼", Style::default().fg(Color::Gray));
230 }
231 }
232 }
233 }
234}
235
236fn 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
245pub 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 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 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 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 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}