Skip to main content

langcodec_cli/tui/
render.rs

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}