1use 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
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(
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 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 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 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 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 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 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 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 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 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 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 if from_col == to_col {
258 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 if from_y < area.bottom() {
269 buf.set_string(from_x, from_y, "│", Style::default().fg(Color::Gray));
270 }
271
272 let mid_y = from_y + 1;
274 if mid_y < area.bottom() && mid_y < to_y {
275 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 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 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 if to_y < area.bottom() {
311 buf.set_string(to_x, to_y, "▼", Style::default().fg(Color::Gray));
312 }
313 }
314 }
315 }
316}
317
318fn 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
330pub 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 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 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 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 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}