1use ratatui::{
2 Frame,
3 layout::{Constraint, Direction, Layout, Rect},
4 style::{Color, Modifier, Style},
5 text::{Line, Span},
6 widgets::{Block, Borders, Cell, Clear, Paragraph, Row, Table, TableState, Wrap},
7};
8
9use crate::tui::{DashboardItemStatus, DashboardKind, DashboardLogTone, DashboardState, FocusPane};
10
11fn tone_style(tone: DashboardLogTone) -> Style {
12 match tone {
13 DashboardLogTone::Info => Style::default().fg(Color::Cyan),
14 DashboardLogTone::Success => Style::default().fg(Color::Green),
15 DashboardLogTone::Warning => Style::default().fg(Color::Yellow),
16 DashboardLogTone::Error => Style::default().fg(Color::Red),
17 }
18}
19
20fn status_style(status: DashboardItemStatus) -> Style {
21 match status {
22 DashboardItemStatus::Queued => Style::default().fg(Color::DarkGray),
23 DashboardItemStatus::Running => Style::default().fg(Color::Yellow),
24 DashboardItemStatus::Succeeded => Style::default().fg(Color::Green),
25 DashboardItemStatus::Failed => Style::default().fg(Color::Red),
26 DashboardItemStatus::Skipped => Style::default().fg(Color::Blue),
27 }
28}
29
30fn focused_block(title: &str, focused: bool) -> Block<'static> {
31 let style = if focused {
32 Style::default().fg(Color::Cyan)
33 } else {
34 Style::default()
35 };
36 Block::default()
37 .title(title.to_string())
38 .borders(Borders::ALL)
39 .border_style(style)
40}
41
42fn key_hints(completed: bool) -> Line<'static> {
43 let base = "Up/Down move Tab focus PgUp/PgDn scroll g/G jump ? help";
44 if completed {
45 Line::from(format!("Press q to close {base}"))
46 } else {
47 Line::from(format!("{base} q closes when finished Ctrl-C interrupt"))
48 }
49}
50
51fn completion_hint_line(completed: bool) -> Line<'static> {
52 if completed {
53 Line::from(vec![
54 Span::styled(
55 "READY TO CLOSE ",
56 Style::default()
57 .fg(Color::Green)
58 .add_modifier(Modifier::BOLD),
59 ),
60 Span::styled(
61 "Press q to close this dashboard.",
62 Style::default()
63 .fg(Color::White)
64 .add_modifier(Modifier::BOLD),
65 ),
66 ])
67 } else {
68 Line::from(vec![
69 Span::styled(
70 "RUNNING ",
71 Style::default()
72 .fg(Color::Yellow)
73 .add_modifier(Modifier::BOLD),
74 ),
75 Span::raw("The dashboard stays open until completion."),
76 ])
77 }
78}
79
80pub fn render_dashboard(frame: &mut Frame<'_>, state: &DashboardState, show_help: bool) {
81 let area = frame.area();
82 let vertical = Layout::default()
83 .direction(Direction::Vertical)
84 .constraints([
85 Constraint::Length(7),
86 Constraint::Min(12),
87 Constraint::Length(5),
88 ])
89 .split(area);
90
91 render_header(frame, vertical[0], state);
92 render_body(frame, vertical[1], state);
93 render_footer(frame, vertical[2], state);
94
95 if show_help {
96 render_help(frame, area, state.completed);
97 }
98}
99
100fn render_header(frame: &mut Frame<'_>, area: Rect, state: &DashboardState) {
101 let title = match state.kind {
102 DashboardKind::Translate => "Translate Dashboard",
103 DashboardKind::Annotate => "Annotate Dashboard",
104 };
105 let lines = std::iter::once(Line::from(Span::styled(
106 format!("{title} · {}", state.title),
107 Style::default().add_modifier(Modifier::BOLD),
108 )))
109 .chain(state.metadata.iter().map(|row| {
110 Line::from(vec![
111 Span::styled(
112 format!("{}: ", row.label),
113 Style::default()
114 .fg(Color::DarkGray)
115 .add_modifier(Modifier::BOLD),
116 ),
117 Span::raw(row.value.clone()),
118 ])
119 }))
120 .collect::<Vec<_>>();
121 frame.render_widget(
122 Paragraph::new(lines)
123 .block(Block::default().borders(Borders::ALL).title("Run"))
124 .wrap(Wrap { trim: false }),
125 area,
126 );
127}
128
129fn render_body(frame: &mut Frame<'_>, area: Rect, state: &DashboardState) {
130 let columns = Layout::default()
131 .direction(Direction::Horizontal)
132 .constraints([Constraint::Percentage(52), Constraint::Percentage(48)])
133 .split(area);
134 let right = Layout::default()
135 .direction(Direction::Vertical)
136 .constraints([Constraint::Percentage(50), Constraint::Percentage(50)])
137 .split(columns[1]);
138
139 render_items(frame, columns[0], state);
140 render_detail(frame, right[0], state);
141 render_logs(frame, right[1], state);
142}
143
144fn render_items(frame: &mut Frame<'_>, area: Rect, state: &DashboardState) {
145 let rows = state.items.iter().map(|item| {
146 Row::new(vec![
147 Cell::from(item.status.label()).style(status_style(item.status)),
148 Cell::from(item.title.clone()),
149 Cell::from(item.subtitle.clone()),
150 ])
151 });
152 let widths = [
153 Constraint::Length(9),
154 Constraint::Percentage(48),
155 Constraint::Percentage(43),
156 ];
157 let table = Table::new(rows, widths)
158 .header(
159 Row::new(vec!["Status", "Item", "Context"]).style(
160 Style::default()
161 .add_modifier(Modifier::BOLD)
162 .fg(Color::Cyan),
163 ),
164 )
165 .block(focused_block("Jobs", state.focus == FocusPane::Table))
166 .row_highlight_style(Style::default().bg(Color::DarkGray))
167 .highlight_symbol(">")
168 .column_spacing(1);
169 let mut table_state = TableState::default().with_selected(if state.items.is_empty() {
170 None
171 } else {
172 Some(state.selected)
173 });
174 frame.render_stateful_widget(table, area, &mut table_state);
175}
176
177fn render_detail(frame: &mut Frame<'_>, area: Rect, state: &DashboardState) {
178 let mut lines = Vec::new();
179 if let Some(item) = state.selected_item() {
180 lines.push(Line::from(Span::styled(
181 format!("{} · {}", item.title, item.subtitle),
182 Style::default().add_modifier(Modifier::BOLD),
183 )));
184 lines.push(Line::from(""));
185 if let Some(source) = &item.source_text {
186 lines.push(Line::from(Span::styled(
187 "Source",
188 Style::default().fg(Color::DarkGray),
189 )));
190 lines.push(Line::from(source.clone()));
191 lines.push(Line::from(""));
192 }
193 if let Some(output) = &item.output_text {
194 lines.push(Line::from(Span::styled(
195 if state.kind == DashboardKind::Translate {
196 "Translation"
197 } else {
198 "Generated comment"
199 },
200 Style::default().fg(Color::DarkGray),
201 )));
202 lines.push(Line::from(output.clone()));
203 lines.push(Line::from(""));
204 }
205 if let Some(note) = &item.note_text {
206 lines.push(Line::from(Span::styled(
207 "Notes",
208 Style::default().fg(Color::DarkGray),
209 )));
210 lines.push(Line::from(note.clone()));
211 lines.push(Line::from(""));
212 }
213 if let Some(error) = &item.error_text {
214 lines.push(Line::from(Span::styled(
215 "Error",
216 Style::default().fg(Color::Red).add_modifier(Modifier::BOLD),
217 )));
218 lines.push(Line::from(error.clone()));
219 lines.push(Line::from(""));
220 }
221 for row in &item.extra_rows {
222 lines.push(Line::from(vec![
223 Span::styled(
224 format!("{}: ", row.label),
225 Style::default().fg(Color::DarkGray),
226 ),
227 Span::raw(row.value.clone()),
228 ]));
229 }
230 } else {
231 lines.push(Line::from("No items"));
232 }
233 frame.render_widget(
234 Paragraph::new(lines)
235 .scroll((state.detail_scroll, 0))
236 .wrap(Wrap { trim: false })
237 .block(focused_block("Detail", state.focus == FocusPane::Detail)),
238 area,
239 );
240}
241
242fn render_logs(frame: &mut Frame<'_>, area: Rect, state: &DashboardState) {
243 let lines = state
244 .logs
245 .iter()
246 .map(|(tone, message)| {
247 Line::from(Span::styled(
248 message.clone(),
249 tone_style(*tone).add_modifier(Modifier::BOLD),
250 ))
251 })
252 .collect::<Vec<_>>();
253 frame.render_widget(
254 Paragraph::new(lines)
255 .scroll((state.log_scroll, 0))
256 .wrap(Wrap { trim: false })
257 .block(focused_block("Events", state.focus == FocusPane::Log)),
258 area,
259 );
260}
261
262fn render_footer(frame: &mut Frame<'_>, area: Rect, state: &DashboardState) {
263 let counts = state.counts();
264 let summary = format!(
265 "queued={} running={} done={} failed={} skipped={}",
266 counts.queued, counts.running, counts.succeeded, counts.failed, counts.skipped
267 );
268 let mut lines = vec![Line::from(summary)];
269 if !state.summary_rows.is_empty() {
270 lines.push(Line::from(
271 state
272 .summary_rows
273 .iter()
274 .map(|row| format!("{}={}", row.label, row.value))
275 .collect::<Vec<_>>()
276 .join(" "),
277 ));
278 }
279 lines.push(completion_hint_line(state.completed));
280 lines.push(key_hints(state.completed));
281 frame.render_widget(
282 Paragraph::new(lines).block(Block::default().borders(Borders::ALL).title("Summary")),
283 area,
284 );
285}
286
287fn render_help(frame: &mut Frame<'_>, area: Rect, completed: bool) {
288 let popup = centered_rect(area, 60, 45);
289 frame.render_widget(Clear, popup);
290 let quit_line = if completed {
291 "q: close the dashboard"
292 } else {
293 "q: available after the run finishes"
294 };
295 let lines = vec![
296 Line::from("Up/Down: move selected item"),
297 Line::from("Tab: cycle focus"),
298 Line::from("PageUp/PageDown: scroll active pane"),
299 Line::from("g / G: jump top/bottom"),
300 Line::from("? : toggle help"),
301 Line::from(quit_line),
302 Line::from("Ctrl-C: interrupt the process"),
303 ];
304 frame.render_widget(
305 Paragraph::new(lines)
306 .block(Block::default().title("Help").borders(Borders::ALL))
307 .wrap(Wrap { trim: false }),
308 popup,
309 );
310}
311
312fn centered_rect(area: Rect, width_percent: u16, height_percent: u16) -> Rect {
313 let vertical = Layout::default()
314 .direction(Direction::Vertical)
315 .constraints([
316 Constraint::Percentage((100 - height_percent) / 2),
317 Constraint::Percentage(height_percent),
318 Constraint::Percentage((100 - height_percent) / 2),
319 ])
320 .split(area);
321 Layout::default()
322 .direction(Direction::Horizontal)
323 .constraints([
324 Constraint::Percentage((100 - width_percent) / 2),
325 Constraint::Percentage(width_percent),
326 Constraint::Percentage((100 - width_percent) / 2),
327 ])
328 .split(vertical[1])[1]
329}
330
331#[cfg(test)]
332mod tests {
333 use ratatui::{Terminal, backend::TestBackend};
334
335 use crate::tui::{
336 DashboardInit, DashboardItem, DashboardItemStatus, DashboardKind, DashboardLogTone,
337 DashboardState, SummaryRow,
338 };
339
340 use super::{completion_hint_line, key_hints, render_dashboard};
341
342 fn render_to_string(state: &DashboardState) -> String {
343 let backend = TestBackend::new(100, 40);
344 let mut terminal = Terminal::new(backend).unwrap();
345 terminal
346 .draw(|frame| render_dashboard(frame, state, false))
347 .unwrap();
348 let buffer = terminal.backend().buffer().clone();
349 buffer
350 .content
351 .iter()
352 .map(|cell| cell.symbol())
353 .collect::<Vec<_>>()
354 .join("")
355 }
356
357 #[test]
358 fn translate_dashboard_renders_title_and_item() {
359 let state = DashboardState::new(DashboardInit {
360 kind: DashboardKind::Translate,
361 title: "en -> fr".to_string(),
362 metadata: vec![SummaryRow::new("Provider", "openai:gpt")],
363 summary_rows: vec![SummaryRow::new("Skipped", "1")],
364 items: vec![DashboardItem::new(
365 "fr:welcome",
366 "welcome",
367 "fr",
368 DashboardItemStatus::Queued,
369 )],
370 });
371 let rendered = render_to_string(&state);
372 assert!(rendered.contains("Translate Dashboard"));
373 assert!(rendered.contains("welcome"));
374 }
375
376 #[test]
377 fn completed_dashboard_renders_failure_summary() {
378 let mut state = DashboardState::new(DashboardInit {
379 kind: DashboardKind::Translate,
380 title: "en -> fr".to_string(),
381 metadata: Vec::new(),
382 summary_rows: vec![SummaryRow::new("Failed", "1")],
383 items: vec![DashboardItem::new(
384 "fr:welcome",
385 "welcome",
386 "fr",
387 DashboardItemStatus::Failed,
388 )],
389 });
390 state.apply(crate::tui::DashboardEvent::Log {
391 tone: DashboardLogTone::Error,
392 message: "network error".to_string(),
393 });
394 state.apply(crate::tui::DashboardEvent::Completed);
395 let rendered = render_to_string(&state);
396 assert!(rendered.contains("failed=1"));
397 assert!(rendered.contains("network error"));
398 assert!(rendered.contains("READY TO CLOSE"));
399 assert!(rendered.contains("Press q to close this dashboard"));
400 }
401
402 #[test]
403 fn key_hints_explain_when_q_is_available() {
404 let running = key_hints(false);
405 let completed = key_hints(true);
406 assert!(
407 running
408 .spans
409 .iter()
410 .any(|span| span.content.contains("q closes when finished"))
411 );
412 assert!(
413 completed
414 .spans
415 .iter()
416 .any(|span| span.content.contains("Press q to close"))
417 );
418 }
419
420 #[test]
421 fn completion_hint_is_explicit_when_finished() {
422 let completed = completion_hint_line(true);
423 assert!(
424 completed
425 .spans
426 .iter()
427 .any(|span| span.content.contains("READY TO CLOSE"))
428 );
429 assert!(
430 completed
431 .spans
432 .iter()
433 .any(|span| span.content.contains("Press q to close this dashboard"))
434 );
435 }
436
437 #[test]
438 fn annotate_dashboard_renders_log_entries() {
439 let mut state = DashboardState::new(DashboardInit {
440 kind: DashboardKind::Annotate,
441 title: "catalog".to_string(),
442 metadata: Vec::new(),
443 summary_rows: vec![SummaryRow::new("Generated", "1")],
444 items: vec![DashboardItem::new(
445 "welcome",
446 "welcome",
447 "catalog",
448 DashboardItemStatus::Running,
449 )],
450 });
451 state.apply(crate::tui::DashboardEvent::Log {
452 tone: DashboardLogTone::Info,
453 message: "Tool call key=welcome tool=shell".to_string(),
454 });
455 let rendered = render_to_string(&state);
456 assert!(rendered.contains("Annotate Dashboard"));
457 assert!(rendered.contains("Tool call key=welcome"));
458 }
459}