Skip to main content

tanu_tui/
lib.rs

1//! # Tanu TUI
2//!
3//! `tanu-tui` is a terminal-based user interface application for managing and executing tests
4//! using the `tanu` framework. It is implemented using the ratatui library and follows the
5//! Elm Architecture, which divides the logic into Model, Update, and View components. The
6//! application has three primary panes: a list of tests, an info view for logs/results, and
7//! a logger for runtime messages. It supports asynchronous test execution and user interaction
8//! via keyboard commands.
9//!
10//! ## UI Architecture (block diagram)
11//!
12//! ```text
13//! +-------------------+     +-------------------+     +-------------------+
14//! | Inputs            | --> | Update (Message)  | --> | Model (state)     |
15//! | keys/mouse/events |     | command dispatch  |     | tests/results/log |
16//! +-------------------+     +-------------------+     +-------------------+
17//!          ^                                                   |
18//!          |                                                   v
19//!     +-------------------+ <-------- render/view -------- +-------------------+
20//!     | Terminal frame    |                                | Widgets           |
21//!     | layout/panes      |                                | List/Info/Logger  |
22//!     +-------------------+                                +-------------------+
23//!
24//! Runner events --------> Model (results/logs) -> Info/Logger widgets
25//! ```
26mod widget;
27
28use crossterm::event::{EventStream, KeyModifiers};
29use eyre::WrapErr;
30use futures::StreamExt;
31use ratatui::{
32    crossterm::event::{Event, KeyCode, KeyEvent, KeyEventKind},
33    layout::Position,
34    prelude::*,
35    style::{Modifier, Style},
36    text::Line,
37    widgets::{
38        Bar, BarChart, BarGroup, Block, BorderType, Borders, LineGauge, Padding, Paragraph,
39    },
40    Frame,
41};
42use std::{
43    collections::{BTreeMap, HashMap, VecDeque},
44    time::Duration,
45};
46use tanu_core::{
47    get_tanu_config,
48    runner::{self, EventBody},
49    Runner, TestInfo,
50};
51use tokio::sync::mpsc;
52use tracing::{error, info, trace};
53use tracing_subscriber::layer::SubscriberExt;
54use tui_big_text::{BigText, PixelSize};
55use tui_logger::{TuiLoggerLevelOutput, TuiLoggerSmartWidget, TuiWidgetEvent, TuiWidgetState};
56
57pub const WHITESPACE: &str = "\u{00A0}";
58
59const SELECTED_STYLE: Style = Style::new().bg(Color::Black).add_modifier(Modifier::BOLD);
60
61use crate::widget::{
62    info::{InfoState, InfoWidget, Tab},
63    list::{ExecutionStateController, TestCaseSelector, TestListState, TestListWidget},
64    tabbed_block::CustomTabs,
65};
66
67/// Represents result of a test case.
68#[derive(Default, Clone, Debug)]
69pub struct TestResult {
70    pub project_name: String,
71    pub module_name: String,
72    pub name: String,
73    pub logs: Vec<Box<tanu_core::http::Log>>,
74    pub test: Option<tanu_core::runner::Test>,
75}
76
77impl TestResult {
78    /// Unique test name including project and module names
79    pub fn unique_name(&self) -> String {
80        format!("{}::{}::{}", self.project_name, self.module_name, self.name)
81    }
82}
83
84#[derive(
85    Debug, Clone, Copy, Default, Eq, PartialEq, strum::FromRepr, strum::EnumString, strum::Display,
86)]
87enum Pane {
88    #[default]
89    List,
90    Info,
91    Logger,
92}
93
94/// Indicates the state of test execution.
95#[derive(Debug, Clone, Copy)]
96enum Execution {
97    /// Executing or executed a test case.
98    One,
99    /// Executing or executed all of the test cases.
100    All,
101}
102
103/// Represents cursor movement.
104#[derive(Debug, Clone, Copy)]
105enum CursorMovement {
106    /// Move the cursor up by one line.
107    Up,
108    /// Move the cursor down by one line.
109    Down,
110    /// Move the cursor up by half of the screen height.
111    UpHalfScreen,
112    /// Move the cursor down by half of the screen height.
113    DownHalfScreen,
114    /// Move the cursor to the first line.
115    Home,
116    /// Move the cursor to the last line.
117    End,
118}
119
120/// Represents tab movement.
121#[derive(Debug, Clone, Copy)]
122enum TabMovement {
123    /// Move tab to the next.
124    Next,
125    /// Move tab to the previous.
126    Prev,
127}
128
129/// Represents the state of the application, including the current pane, execution state, test cases, and UI components' states.
130struct Model {
131    /// Indicates whether the current pane is in maximized view mode
132    maximizing: bool,
133    /// Keeps track of which pane (List, Console, Logger) is currently focused
134    current_pane: Pane,
135    /// Stores the current execution state, which can be either executing one test, all tests, or none
136    current_exec: Option<Execution>,
137    /// Manages the selection state for the list of test cases
138    test_cases_list: TestListState,
139    /// Contains the results of executed tests, including logs and the test itself
140    test_results: Vec<TestResult>,
141    /// Maintains the state of the info pange, such as currently selected tab.
142    info_state: InfoState,
143    /// Holds the state of the logger pane, including any focus or visibility settings
144    logger_state: TuiWidgetState,
145    /// Stores the last mouse click event, if any. When `click` is not `None`, it indicates that the user has clicked on a specific area of the UI.
146    click: Option<crossterm::event::MouseEvent>,
147    /// Measures the frames per second (FPS).
148    fps_counter: FpsCounter,
149}
150
151impl Model {
152    fn new(test_cases: Vec<TestInfo>) -> Model {
153        let cfg = get_tanu_config();
154        Model {
155            maximizing: false,
156            current_pane: Pane::default(),
157            current_exec: None,
158            test_cases_list: TestListState::new(&cfg.projects, &test_cases),
159            test_results: vec![],
160            info_state: InfoState::new(),
161            logger_state: TuiWidgetState::new(),
162            click: None,
163            fps_counter: FpsCounter::new(),
164        }
165    }
166
167    fn next_pane(&mut self) {
168        let current_index = self.current_pane as usize;
169        let pane_counts = Pane::Logger as usize + 1;
170        let next_index = (current_index + 1) % pane_counts;
171        if let Some(next_pane) = Pane::from_repr(next_index) {
172            self.current_pane = next_pane;
173        }
174        self.info_state.focused = self.current_pane == Pane::Info;
175    }
176}
177
178#[derive(Debug)]
179enum Message {
180    Maximize,
181    NextPane,
182    ListSelect(CursorMovement),
183    ListExpand,
184    InfoSelect(CursorMovement),
185    InfoTabSelect(TabMovement),
186    LoggerSelectDown,
187    LoggerSelectUp,
188    LoggerSelectLeft,
189    LoggerSelectRight,
190    LoggerSelectSpace,
191    LoggerSelectHide,
192    LoggerSelectFocus,
193    ExecuteOne,
194    ExecuteAll,
195    SelectPane(crossterm::event::MouseEvent),
196}
197
198#[derive(Debug)]
199enum Command {
200    ExecuteOne(TestCaseSelector),
201    ExecuteAll,
202}
203
204/// Reset the offset of the list or info pane.
205fn offset_begin(model: &mut Model) {
206    match model.info_state.selected_tab {
207        Tab::Payload => {
208            model.info_state.payload_state.scroll_offset = 0;
209        }
210        Tab::Error => {
211            model.info_state.error_state.scroll_offset = 0;
212        }
213        _ => {}
214    }
215}
216
217/// Move the offset of the list or info pane to the last.
218fn offset_end(_model: &mut Model) {
219    // TODO
220}
221
222/// Move down the offset of the list or info pane.
223fn offset_down(model: &mut Model, val: i16) {
224    match model.info_state.selected_tab {
225        Tab::Payload => {
226            model.info_state.payload_state.scroll_offset += val as u16;
227        }
228        Tab::Error => {
229            model.info_state.error_state.scroll_offset += val as u16;
230        }
231        _ => {}
232    }
233}
234
235/// Move up the offset of the model.
236fn offset_up(model: &mut Model, val: i16) {
237    match model.info_state.selected_tab {
238        Tab::Payload => {
239            model.info_state.payload_state.scroll_offset = model
240                .info_state
241                .payload_state
242                .scroll_offset
243                .saturating_sub(val as u16);
244        }
245        Tab::Error => {
246            model.info_state.error_state.scroll_offset = model
247                .info_state
248                .error_state
249                .scroll_offset
250                .saturating_sub(val as u16);
251        }
252        _ => {}
253    }
254    if model.info_state.selected_tab == Tab::Error {}
255}
256
257async fn update(model: &mut Model, msg: Message) -> eyre::Result<Option<Command>> {
258    model.click = None;
259
260    let terminal_height = crossterm::terminal::size()?.1 as usize;
261    match msg {
262        Message::Maximize => {
263            model.maximizing = !model.maximizing;
264        }
265        Message::NextPane => {
266            model.next_pane();
267        }
268        Message::ListSelect(CursorMovement::Down) => model.test_cases_list.list_state.select_next(),
269        Message::ListSelect(CursorMovement::Up) => {
270            model.test_cases_list.list_state.select_previous();
271        }
272        Message::ListSelect(CursorMovement::UpHalfScreen) => {
273            let offset = terminal_height / 4;
274            let selected = model
275                .test_cases_list
276                .list_state
277                .selected()
278                .unwrap_or_default();
279            model
280                .test_cases_list
281                .list_state
282                .select(Some(selected.saturating_sub(offset)));
283        }
284        Message::ListSelect(CursorMovement::DownHalfScreen) => {
285            let offset = terminal_height / 4;
286            let selected = model
287                .test_cases_list
288                .list_state
289                .selected()
290                .unwrap_or_default();
291            model
292                .test_cases_list
293                .list_state
294                .select(Some(selected + offset));
295        }
296        Message::ListSelect(CursorMovement::Home) => {
297            model.test_cases_list.list_state.select_first();
298        }
299        Message::ListSelect(CursorMovement::End) => {
300            model.test_cases_list.list_state.select_last();
301        }
302        Message::ListExpand => model.test_cases_list.expand(&model.test_results),
303        Message::InfoSelect(CursorMovement::Down) => {
304            offset_down(model, 1);
305        }
306        Message::InfoSelect(CursorMovement::DownHalfScreen) => {
307            offset_down(model, (terminal_height / 2) as i16);
308        }
309        Message::InfoSelect(CursorMovement::Up) => {
310            offset_up(model, 1);
311        }
312        Message::InfoSelect(CursorMovement::UpHalfScreen) => {
313            offset_up(model, (terminal_height / 2) as i16);
314        }
315        Message::InfoSelect(CursorMovement::Home) => {
316            offset_begin(model);
317        }
318        Message::InfoSelect(CursorMovement::End) => {
319            offset_end(model);
320        }
321        Message::InfoTabSelect(TabMovement::Next) => {
322            model.info_state.next_tab();
323        }
324        Message::InfoTabSelect(TabMovement::Prev) => {
325            model.info_state.prev_tab();
326        }
327
328        Message::LoggerSelectDown => model.logger_state.transition(TuiWidgetEvent::DownKey),
329        Message::LoggerSelectUp => model.logger_state.transition(TuiWidgetEvent::UpKey),
330        Message::LoggerSelectLeft => model.logger_state.transition(TuiWidgetEvent::LeftKey),
331        Message::LoggerSelectRight => model.logger_state.transition(TuiWidgetEvent::RightKey),
332        Message::LoggerSelectSpace => model.logger_state.transition(TuiWidgetEvent::SpaceKey),
333        Message::LoggerSelectHide => model.logger_state.transition(TuiWidgetEvent::HideKey),
334        Message::LoggerSelectFocus => model.logger_state.transition(TuiWidgetEvent::FocusKey),
335        Message::ExecuteOne => {
336            model.current_exec = Some(Execution::One);
337            let Some(selector) = model.test_cases_list.select_test_case(&model.test_results) else {
338                return Ok(None);
339            };
340            ExecutionStateController::execute_specified(&mut model.test_cases_list, &selector);
341            return Ok(Some(Command::ExecuteOne(selector)));
342        }
343        Message::ExecuteAll => {
344            model.test_results.clear();
345            model.current_exec = Some(Execution::All);
346            ExecutionStateController::execute_all(&mut model.test_cases_list);
347            return Ok(Some(Command::ExecuteAll));
348        }
349        Message::SelectPane(click) => {
350            model.click = Some(click);
351        }
352    }
353
354    model.info_state.selected_test = model.test_cases_list.select_test_case(&model.test_results);
355
356    Ok(None)
357}
358
359/// Construct UI.
360fn view(model: &mut Model, frame: &mut Frame) {
361    trace!("rendering view");
362
363    let [layout_main, layout_menu, layout_gauge] = Layout::vertical([
364        Constraint::Min(0),
365        Constraint::Length(1),
366        Constraint::Length(1),
367    ])
368    .areas(frame.area());
369    let [layout_left, layout_right] =
370        Layout::horizontal([Constraint::Percentage(50), Constraint::Percentage(50)])
371            .areas(layout_main);
372    let [layout_rightup, layout_rightdown] =
373        Layout::vertical([Constraint::Percentage(70), Constraint::Percentage(30)])
374            .areas(layout_right);
375    let [layout_histogram, layout_summary] =
376        Layout::horizontal([Constraint::Percentage(70), Constraint::Percentage(30)])
377            .areas(layout_rightdown);
378    let layout_right_inner = Layout::default()
379        .constraints([Constraint::Percentage(100)])
380        .margin(1)
381        .split(layout_rightup)[0];
382    let [_, layout_tabs, layout_info] = Layout::vertical([
383        Constraint::Length(1),
384        Constraint::Length(2),
385        Constraint::Min(0),
386    ])
387    .areas(layout_right_inner);
388    let [layout_logo, layout_list, layout_logger] = Layout::vertical([
389        Constraint::Min(3),
390        Constraint::Percentage(50),
391        Constraint::Percentage(50),
392    ])
393    .areas(layout_left);
394    let [layout_logo, layout_fps_area] =
395        Layout::horizontal([Constraint::Fill(1), Constraint::Length(9)]).areas(layout_logo);
396    let [layout_fps, layout_version] = Layout::vertical([
397        Constraint::Length(1),
398        Constraint::Length(1),
399    ])
400    .areas(layout_fps_area);
401    let layout_menu_items = Layout::default()
402        .direction(Direction::Horizontal)
403        .constraints([
404            Constraint::Length(9),  // q
405            Constraint::Length(13), // z
406            Constraint::Length(12), // 1
407            Constraint::Length(8),  // 2
408            Constraint::Length(16), // tab
409            Constraint::Length(15), // ←|→
410            Constraint::Length(14), // ↑|↓
411            Constraint::Length(26), // CTRL+U|CTRL+D
412            Constraint::Length(10), // g
413            Constraint::Length(9),  // G
414            Constraint::Length(15), // Enter
415        ])
416        .split(layout_menu);
417
418    // Handle mouse click events on UI. If position is in the list pane area, switch to it.
419    let click_position = model.click.as_ref().map(|click| {
420        let x = click.column;
421        let y = click.row;
422        Position::from((x, y))
423    });
424    if let Some(position) = click_position {
425        if layout_list.contains(position) {
426            model.current_pane = Pane::List;
427            model.info_state.focused = false;
428        } else if layout_info.contains(position) {
429            model.current_pane = Pane::Info;
430            model.info_state.focused = true;
431        } else if layout_tabs.contains(position) {
432            model.current_pane = Pane::Info;
433            model.info_state.focused = true;
434
435            // Check which tab was clicked.
436            let mut left = layout_tabs.left();
437            for tab in [Tab::Call, Tab::Headers, Tab::Payload, Tab::Error] {
438                const TAB_PADDING: u16 = 4;
439                const TAB_DIVIDER: u16 = 1;
440                let tab_length = tab.to_string().len() as u16 + TAB_PADDING;
441                if position.x >= left && position.x <= left + tab_length {
442                    model.info_state.selected_tab = tab;
443                    break;
444                }
445                left += tab_length + TAB_DIVIDER;
446            }
447        } else if layout_logger.contains(position) {
448            model.current_pane = Pane::Logger;
449            model.info_state.focused = false;
450        }
451    }
452
453    let fps = Paragraph::new(format!("FPS:{:.1}", model.fps_counter.fps))
454        .alignment(Alignment::Right)
455        .style(Style::default().dim());
456    let version = Paragraph::new(format!("v{}", env!("CARGO_PKG_VERSION")))
457        .alignment(Alignment::Right)
458        .style(Style::default().dim());
459
460    let ratio =
461        (model.test_results.len() as f64 / model.test_cases_list.len() as f64).clamp(0.0, 1.0);
462    let gauge = LineGauge::default()
463        .block(
464            Block::default()
465                .borders(Borders::NONE)
466                .padding(Padding::new(1, 1, 0, 0)),
467        )
468        .filled_style(Style::new().blue())
469        .unfilled_style(Style::new().black())
470        .ratio(ratio)
471        .label(if ratio == 0.0 {
472            "".to_string() // Hide label when no tests are running
473        } else {
474            format!("{}%", (ratio * 100.0).round() as u32)
475        });
476
477    let menu_items = [
478        ("[q]", "Quit"),
479        ("[z]", "Maximize"),
480        ("[1]", "Run ALL"),
481        ("[2]", "Run"),
482        ("[Tab]", "Next Pane"),
483        ("[←|→]", "Next Tab"),
484        ("[↑|↓]", "Up/Down"),
485        if matches!(model.current_pane, Pane::List | Pane::Info) {
486            ("[CTRL+U|D]", "Scroll Up/Down")
487        } else {
488            ("", "")
489        },
490        if matches!(model.current_pane, Pane::List | Pane::Info) {
491            ("[g]", "First")
492        } else {
493            ("", "")
494        },
495        if matches!(model.current_pane, Pane::List | Pane::Info) {
496            ("[G]", "Last")
497        } else {
498            ("", "")
499        },
500        if matches!(model.current_pane, Pane::List) {
501            ("[Enter]", "Expand")
502        } else {
503            ("", "")
504        },
505    ];
506
507    for (n, &(key, label)) in menu_items.iter().enumerate() {
508        let menu_item = Paragraph::new(vec![Line::from(vec![
509            Span::styled(key, Style::default().fg(Color::Blue).bold()),
510            Span::styled(format!("{WHITESPACE}{label}"), Style::default()),
511        ])])
512        .block(Block::default().borders(Borders::NONE));
513        frame.render_widget(menu_item, layout_menu_items[n]);
514    }
515
516    let info_block = Block::default()
517        .border_type(if model.info_state.focused {
518            BorderType::Thick
519        } else {
520            BorderType::Plain
521        })
522        .border_style(if model.info_state.focused {
523            Style::default().fg(Color::Blue).bold()
524        } else {
525            Style::default().fg(Color::Blue)
526        })
527        .borders(Borders::ALL)
528        .title("Request/Response".bold());
529
530    let tabs = CustomTabs::new(vec!["Call", "Headers", "Payload", "Error"])
531        .select(model.info_state.selected_tab as usize)
532        .selected_style(Style::default().fg(Color::Blue).bold());
533
534    let info = InfoWidget::new(model.test_results.clone());
535
536    let logo = BigText::builder()
537        .pixel_size(PixelSize::Sextant)
538        .style(Style::new().fg(Color::Blue))
539        .lines(vec!["tanu".into()])
540        .build();
541
542    let test_list = TestListWidget::new(
543        matches!(model.current_pane, Pane::List),
544        &model.test_cases_list.projects,
545    );
546
547    let logger = TuiLoggerSmartWidget::default()
548        .title_target("Selector".bold())
549        .title_log("Logs".bold())
550        .border_type(if matches!(model.current_pane, Pane::Logger) {
551            BorderType::Thick
552        } else {
553            BorderType::Plain
554        })
555        .border_style(if matches!(model.current_pane, Pane::Logger) {
556            Style::default().fg(Color::Blue).bold()
557        } else {
558            Style::default().fg(Color::Blue)
559        })
560        .style_error(Style::default().fg(Color::Red))
561        .style_warn(Style::default().fg(Color::Yellow))
562        .style_info(Style::default())
563        .style_debug(Style::default().dim())
564        .style_trace(Style::default().dim())
565        .output_separator('|')
566        .output_timestamp(None)
567        .output_level(Some(TuiLoggerLevelOutput::Long))
568        .output_target(false)
569        .output_file(false)
570        .output_line(false)
571        .state(&model.logger_state);
572
573    const BAR_WIDTH: usize = 5;
574    let max_duration = model
575        .test_results
576        .iter()
577        .flat_map(|test| {
578            test.logs
579                .iter()
580                .map(|log| log.response.duration_req.as_millis())
581        })
582        .max()
583        .unwrap_or_default();
584
585    // Decide such number of buckets that histogram bars stretch to the width of the pane.
586    let pane_width = layout_rightdown.width as usize;
587    let mut num_buckets = (pane_width / BAR_WIDTH).max(1);
588    if model.test_results.is_empty() {
589        num_buckets = 1;
590    }
591
592    fn decide_bar_size(value: u128) -> u128 {
593        let exponent = (value as f64).log10().ceil() as i32 - 1;
594        let magnitude = if exponent >= 0 {
595            10u128.saturating_pow(exponent as u32)
596        } else {
597            1 // Default to 1 if the exponent is negative
598        };
599        value.div_ceil(magnitude) * magnitude
600    }
601
602    let bucket_size = decide_bar_size((max_duration / num_buckets as u128).max(1));
603
604    let mut buckets: BTreeMap<u64, usize> = (1..num_buckets).map(|i| (i as u64, 0)).collect();
605    for test in &model.test_results {
606        for log in &test.logs {
607            let bucket = ((log.response.duration_req.as_millis() as f64) / (bucket_size as f64))
608                .ceil() as u64;
609            *buckets.entry(bucket).or_default() += 1;
610        }
611    }
612
613    let histogram_raw_data = buckets
614        .iter()
615        .map(|(k, v)| ((k * bucket_size as u64).to_string(), *v as u64))
616        .collect::<Vec<_>>();
617    let histogram_data = histogram_raw_data
618        .iter()
619        .map(|(k, v)| (k.as_str(), *v))
620        .collect::<Vec<_>>();
621    let histogram: BarChart<'_> = BarChart::default()
622        .data(&histogram_data)
623        .block(
624            Block::new()
625                .title("Latency [ms]".bold())
626                .borders(Borders::ALL)
627                .border_style(Style::default().fg(Color::Blue))
628                .padding(Padding::top(1)),
629        )
630        .bar_width(BAR_WIDTH as u16)
631        .bar_gap(1)
632        .bar_style(Style::default().fg(Color::Blue));
633
634    // Aggregate all test results across all projects
635    let successful = model
636        .test_results
637        .iter()
638        .filter(|result| result.test.as_ref().is_some_and(|test| test.result.is_ok()))
639        .count();
640    let total = model.test_results.len();
641    let failed = total - successful;
642
643    // Create a single bar group for aggregated results
644    let bar_groups = vec![BarGroup::default()
645        .bars(&[
646            Bar::default()
647                .value(successful as u64)
648                .label(Line::from(if total > 0 { "ok" } else { "" }).centered())
649                .text_value(if total > 0 { format!("{successful}") } else { String::new() })
650                .value_style(Style::new().bg(Color::Blue).fg(Color::Black))
651                .style(Color::Blue),
652            Bar::default()
653                .value(failed as u64)
654                .label(Line::from(if total > 0 { "err" } else { "" }).centered())
655                .text_value(if total > 0 { format!("{failed}") } else { String::new() })
656                .value_style(Style::new().bg(Color::Blue).fg(Color::Black))
657                .style(Color::Blue),
658        ])];
659
660    // Create the bar chart with vertical orientation
661    let mut bar_chart = BarChart::default()
662        .block(
663            Block::new()
664                .title("Summary".bold())
665                .borders(Borders::ALL)
666                .border_style(Style::default().fg(Color::Blue))
667                .padding(Padding::top(1)),
668        )
669        .direction(Direction::Vertical)
670        .bar_width(BAR_WIDTH as u16)
671        .bar_gap(1)
672        .group_gap(2);
673
674    for bar_group in bar_groups {
675        bar_chart = bar_chart.data(bar_group);
676    }
677
678    if model.maximizing {
679        match model.current_pane {
680            Pane::List => {
681                frame.render_stateful_widget(test_list, layout_main, &mut model.test_cases_list)
682            }
683            Pane::Info => frame.render_stateful_widget(info, layout_main, &mut model.info_state),
684            Pane::Logger => frame.render_widget(logger, layout_main),
685        }
686    } else {
687    frame.render_widget(fps, layout_fps);
688    frame.render_widget(version, layout_version);
689        frame.render_widget(gauge, layout_gauge);
690        frame.render_widget(logo, layout_logo);
691        frame.render_stateful_widget(test_list, layout_list, &mut model.test_cases_list);
692        frame.render_widget(logger, layout_logger);
693        frame.render_widget(info_block, layout_rightup);
694        frame.render_widget(tabs, layout_tabs);
695        frame.render_stateful_widget(info, layout_info, &mut model.info_state);
696        frame.render_widget(histogram, layout_histogram);
697        frame.render_widget(bar_chart, layout_summary);
698    }
699}
700
701/// The Runtime the application.
702struct Runtime {
703    should_exit: bool,
704}
705
706impl Runtime {
707    const FRAMES_PER_SECOND: f32 = 60.0;
708
709    fn new() -> Runtime {
710        Runtime { should_exit: false }
711    }
712
713    async fn run(
714        mut self,
715        mut runner: Runner,
716        mut terminal: ratatui::DefaultTerminal,
717    ) -> eyre::Result<()> {
718        let period = Duration::from_secs_f32(1.0 / Self::FRAMES_PER_SECOND);
719        let mut draw_interval = tokio::time::interval(period);
720        let mut cmds_interval = tokio::time::interval(period);
721        let mut scrl_interval = tokio::time::interval(Duration::from_secs_f32(0.05));
722        let mut thrb_interval = tokio::time::interval(Duration::from_secs_f32(0.1));
723        let mut event_stream = EventStream::new();
724
725        let test_cases = runner.list().into_iter().cloned().collect();
726        let mut model = Model::new(test_cases);
727        let mut cmds = VecDeque::<Command>::new();
728
729        let (runner_tx, mut runner_rx, mut runner_task) = {
730            let (runner_tx, mut runner_rx) = mpsc::unbounded_channel::<Command>();
731            let runner_task = tokio::spawn(async move {
732                while let Some(cmd) = runner_rx.recv().await {
733                    match cmd {
734                        Command::ExecuteOne(selector) => {
735                            info!(
736                                "running the selected test case: project={} module={} test={}",
737                                selector.project,
738                                selector.module.as_deref().unwrap_or_default(),
739                                selector.test.as_deref().unwrap_or_default()
740                            );
741                            if let Err(e) = runner
742                                .run(
743                                    &[selector.project],
744                                    selector.module.into_iter().collect::<Vec<_>>().as_slice(),
745                                    selector.test.into_iter().collect::<Vec<_>>().as_slice(),
746                                )
747                                .await
748                            {
749                                error!("{e:#}");
750                            }
751                        }
752                        Command::ExecuteAll => {
753                            info!("running all test cases");
754                            if let Err(e) = runner.run(&[], &[], &[]).await {
755                                error!("{e:#}");
756                            }
757                        }
758                    }
759                }
760                info!("command queue for tanu runner terminated");
761            });
762            let runner_rx = tanu_core::runner::subscribe()?;
763            (runner_tx, runner_rx, runner_task)
764        };
765        let mut test_results_buffer = HashMap::<(String, String), TestResult>::new();
766
767        while !self.should_exit && !panic_occurred() {
768            tokio::select! {
769                _ = draw_interval.tick() => {
770                    model.fps_counter.update();
771                    let start_draw = std::time::Instant::now();
772                    terminal.draw(|frame| view(&mut model, frame))?;
773                    trace!("Took {:?} to draw", start_draw.elapsed());
774                },
775                _ = cmds_interval.tick() => {
776                    if let Some(cmd) = cmds.pop_front() {
777                        let _ = runner_tx.send(cmd);
778                    }
779                }
780                _ = scrl_interval.tick() => {
781                }
782                _ = thrb_interval.tick() => {
783                    ExecutionStateController::update_throbber(&mut model.test_cases_list);
784                }
785                _ = &mut runner_task => {
786                }
787                Ok(msg) = runner_rx.recv() => {
788                    match msg {
789                        runner::Event {project, module, test, body: EventBody::Start} => {
790                            test_results_buffer.insert((project.clone(), test.clone()), TestResult {
791                                project_name: project,
792                                module_name: module,
793                                name: test,
794                                ..Default::default()
795                            });
796                        },
797                        runner::Event {project: _, module: _, test: _, body: EventBody::Check(_)} => {
798                        }
799                        runner::Event {project, module: _, test, body: EventBody::Http(log)} => {
800                            if let Some(test_result) = test_results_buffer.get_mut(&(project, test)) {
801                                test_result.logs.push(log);
802                            } else {
803                                // TODO error
804                            }
805                        },
806                        runner::Event {project: _, module: _, test: _, body: EventBody::Retry(_)} => {
807                        }
808                        runner::Event {project, module, test: test_name, body: EventBody::End(test)} => {
809                            if let Some(mut test_result) = test_results_buffer.remove(&(project.clone(), test_name.clone())) {
810                                test_result.test = Some(test);
811                                ExecutionStateController::on_test_updated(
812                                    &mut model.test_cases_list,
813                                    &project,
814                                    &module,
815                                    &test_name,
816                                    test_result.clone(),
817                                );
818                                model.test_results.push(test_result);
819                            } else {
820                                // TODO error
821                            }
822                        },
823                        runner::Event {project: _, module: _, test: _, body: EventBody::Summary(_summary)} => {
824                            // Summary events are handled by the reporters, no TUI action needed
825                        },
826
827                    }
828                }
829                Some(Ok(event)) = event_stream.next() => {
830                    let msg = match event {
831                        Event::Key(key) => {
832                            match key.code {
833                                KeyCode::Char('q') | KeyCode::Esc => {
834                                    self.should_exit = true;
835                                    continue;
836                                },
837                                _ => {
838                                    self.handle_key(key, model.current_pane)
839                                }
840                            }
841                        },
842                        Event::Mouse(mouse) => {
843                            // Only send SelectPane message for click events
844                            if mouse.kind == crossterm::event::MouseEventKind::Down(crossterm::event::MouseButton::Left) {
845                                Some(Message::SelectPane(mouse))
846                            } else {
847                                None
848                            }
849                        },
850                        _ => {
851                            continue;
852                        }
853                    };
854                    let Some(msg) = msg else {
855                        continue;
856                    };
857                    if let Ok(Some(cmd)) = update(&mut model, msg).await {
858                        cmds.push_back(cmd);
859                    }
860                    trace!("updated {:?}", model.test_cases_list);
861                }
862            }
863        }
864
865        // Note: Terminal cleanup is handled by restore_terminal() in the run() function
866        // or by the panic hook if a panic occurs
867        Ok(())
868    }
869
870    fn handle_key(&mut self, key: KeyEvent, current_pane: Pane) -> Option<Message> {
871        trace!("key = {key:?}, current_pane = {current_pane:?}");
872
873        if key.kind != KeyEventKind::Press {
874            return None;
875        }
876        let modifier = key.modifiers;
877
878        match (current_pane, key.code, modifier) {
879            (_, KeyCode::Char('z'), _) => Some(Message::Maximize),
880            (_, KeyCode::BackTab, KeyModifiers::SHIFT) => {
881                Some(Message::InfoTabSelect(TabMovement::Next))
882            }
883            (_, KeyCode::Tab, _) => Some(Message::NextPane),
884            (Pane::Info, KeyCode::Char('j') | KeyCode::Down, _) => {
885                Some(Message::InfoSelect(CursorMovement::Down))
886            }
887            (Pane::Info, KeyCode::Char('k') | KeyCode::Up, _) => {
888                Some(Message::InfoSelect(CursorMovement::Up))
889            }
890            (Pane::Info, KeyCode::Char('h') | KeyCode::Left, _) => {
891                Some(Message::InfoTabSelect(TabMovement::Prev))
892            }
893            (Pane::Info, KeyCode::Char('l') | KeyCode::Right, _) => {
894                Some(Message::InfoTabSelect(TabMovement::Next))
895            }
896            (Pane::Info, KeyCode::Char('g') | KeyCode::Home, _) => {
897                Some(Message::InfoSelect(CursorMovement::Home))
898            }
899            (Pane::Info, KeyCode::Char('G') | KeyCode::End, _) => {
900                Some(Message::InfoSelect(CursorMovement::End))
901            }
902            (Pane::Info, KeyCode::Char('d'), KeyModifiers::CONTROL) => {
903                Some(Message::InfoSelect(CursorMovement::DownHalfScreen))
904            }
905            (Pane::Info, KeyCode::Char('u'), KeyModifiers::CONTROL) => {
906                Some(Message::InfoSelect(CursorMovement::UpHalfScreen))
907            }
908            (Pane::Info, KeyCode::Char('1'), _) => Some(Message::ExecuteAll),
909            (Pane::List, KeyCode::Char('j') | KeyCode::Down, _) => {
910                Some(Message::ListSelect(CursorMovement::Down))
911            }
912            (Pane::List, KeyCode::Char('k') | KeyCode::Up, _) => {
913                Some(Message::ListSelect(CursorMovement::Up))
914            }
915            (Pane::List, KeyCode::Char('g') | KeyCode::Home, _) => {
916                Some(Message::ListSelect(CursorMovement::Home))
917            }
918            (Pane::List, KeyCode::Char('G') | KeyCode::End, _) => {
919                Some(Message::ListSelect(CursorMovement::End))
920            }
921            (Pane::List, KeyCode::Char('d'), KeyModifiers::CONTROL) => {
922                Some(Message::ListSelect(CursorMovement::DownHalfScreen))
923            }
924            (Pane::List, KeyCode::Char('u'), KeyModifiers::CONTROL) => {
925                Some(Message::ListSelect(CursorMovement::UpHalfScreen))
926            }
927            (Pane::List, KeyCode::Char('h') | KeyCode::Left, _) => {
928                Some(Message::InfoTabSelect(TabMovement::Prev))
929            }
930            (Pane::List, KeyCode::Char('l') | KeyCode::Right, _) => {
931                Some(Message::InfoTabSelect(TabMovement::Next))
932            }
933            (Pane::List, KeyCode::Enter, _) => Some(Message::ListExpand),
934            (Pane::List, KeyCode::Char('1'), _) => Some(Message::ExecuteAll),
935            (Pane::List, KeyCode::Char('2'), _) => Some(Message::ExecuteOne),
936            (Pane::Logger, KeyCode::Char('j') | KeyCode::Down, _) => {
937                Some(Message::LoggerSelectDown)
938            }
939            (Pane::Logger, KeyCode::Char('k') | KeyCode::Up, _) => Some(Message::LoggerSelectUp),
940            (Pane::Logger, KeyCode::Char('h') | KeyCode::Left, _) => {
941                Some(Message::LoggerSelectLeft)
942            }
943            (Pane::Logger, KeyCode::Char('l') | KeyCode::Right, _) => {
944                Some(Message::LoggerSelectRight)
945            }
946            (Pane::Logger, KeyCode::Char(' '), _) => Some(Message::LoggerSelectSpace),
947            (Pane::Logger, KeyCode::Char('H'), _) => Some(Message::LoggerSelectHide),
948            (Pane::Logger, KeyCode::Char('F'), _) => Some(Message::LoggerSelectFocus),
949            _ => {
950                // Ignore other keys
951                None
952            }
953        }
954    }
955}
956
957/// Flag to signal that a panic occurred and the TUI should exit.
958static PANIC_OCCURRED: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
959
960fn panic_occurred() -> bool {
961    PANIC_OCCURRED.load(std::sync::atomic::Ordering::SeqCst)
962}
963
964/// Restores the terminal to its original state.
965fn restore_terminal() {
966    use std::io::Write;
967    let _ = crossterm::terminal::disable_raw_mode();
968    let mut stdout = std::io::stdout().lock();
969    let _ = crossterm::execute!(
970        stdout,
971        crossterm::event::DisableMouseCapture,
972        crossterm::terminal::LeaveAlternateScreen,
973        crossterm::cursor::Show,
974    );
975    let _ = stdout.flush();
976}
977
978/// Installs a panic hook that restores the terminal and exits cleanly.
979fn install_panic_hook() {
980    let original_hook = std::panic::take_hook();
981    std::panic::set_hook(Box::new(move |panic_info| {
982        PANIC_OCCURRED.store(true, std::sync::atomic::Ordering::SeqCst);
983        restore_terminal();
984        original_hook(panic_info);
985    }));
986}
987
988/// Runs the tanu terminal user interface application.
989///
990/// Initializes and runs the interactive TUI for managing and executing tanu tests.
991/// The TUI provides three main panes: test list, test information/console, and logger.
992/// Users can navigate with keyboard shortcuts to select tests, run them individually
993/// or in bulk, and monitor execution in real-time.
994///
995/// # Parameters
996///
997/// - `runner`: The configured test runner containing test cases and configuration
998/// - `log_level`: General logging level for the TUI and external libraries
999/// - `tanu_log_level`: Specific logging level for tanu framework components
1000///
1001/// # Features
1002///
1003/// - **Interactive Test Selection**: Browse and select tests with arrow keys
1004/// - **Real-time Execution**: Watch tests run with live updates and logs
1005/// - **HTTP Request Monitoring**: View detailed HTTP request/response data
1006/// - **Concurrent Execution**: Run multiple tests simultaneously
1007/// - **Filtering**: Filter tests by project, module, or name
1008/// - **Logging**: Integrated logger pane for debugging
1009///
1010/// # Keyboard Shortcuts
1011///
1012/// - `↑/↓`: Navigate test list
1013/// - `Enter`: Run selected test
1014/// - `a`: Run all tests
1015/// - `Tab`: Switch between panes
1016/// - `q`/`Esc`: Quit application
1017/// - `Ctrl+U/D`: Page up/down in test list
1018/// - `Home/End`: Go to first/last test
1019///
1020/// # Examples
1021///
1022/// ```rust,ignore
1023/// use tanu_core::Runner;
1024/// use tanu_tui::run;
1025///
1026/// let runner = Runner::new();
1027/// run(runner, log::LevelFilter::Info, log::LevelFilter::Debug).await?;
1028/// ```
1029///
1030/// # Errors
1031///
1032/// Returns an error if:
1033/// - Terminal initialization fails
1034/// - Logger setup fails
1035/// - Test execution encounters unrecoverable errors
1036/// - TUI rendering fails
1037pub async fn run(
1038    runner: Runner,
1039    log_level: log::LevelFilter,
1040    tanu_log_level: log::LevelFilter,
1041) -> eyre::Result<()> {
1042    tracing_log::LogTracer::init()?;
1043    tui_logger::init_logger(log_level)?;
1044    tui_logger::set_level_for_target("tanu", tanu_log_level);
1045    tui_logger::set_level_for_target("tanu_core", tanu_log_level);
1046    tui_logger::set_level_for_target("tanu_core::assertion", tanu_log_level);
1047    tui_logger::set_level_for_target("tanu_core::config", tanu_log_level);
1048    tui_logger::set_level_for_target("tanu_core::http", tanu_log_level);
1049    tui_logger::set_level_for_target("tanu_core::reporter", tanu_log_level);
1050    tui_logger::set_level_for_target("tanu_core::runner", tanu_log_level);
1051    tui_logger::set_level_for_target("tanu_tui", tanu_log_level);
1052    tui_logger::set_level_for_target("tanu_tui::widget", tanu_log_level);
1053    tui_logger::set_level_for_target("tanu_tui::widget::info", tanu_log_level);
1054    tui_logger::set_level_for_target("tanu_tui::widget::list", tanu_log_level);
1055    let subscriber =
1056        tracing_subscriber::Registry::default().with(tui_logger::TuiTracingSubscriberLayer);
1057    tracing::subscriber::set_global_default(subscriber)
1058        .wrap_err("failed to set global default subscriber")?;
1059
1060    if std::env::var("RUST_BACKTRACE").is_err() {
1061        std::env::set_var("RUST_BACKTRACE", "full");
1062    }
1063    if std::env::var("COLORBT_SHOW_HIDDEN").is_err() {
1064        std::env::set_var("COLORBT_SHOW_HIDDEN", "1");
1065    }
1066
1067    dotenv::dotenv().ok();
1068
1069    install_panic_hook();
1070    PANIC_OCCURRED.store(false, std::sync::atomic::Ordering::SeqCst);
1071
1072    // Reset terminal in case a previous run crashed
1073    let _ = crossterm::terminal::disable_raw_mode();
1074    let _ = crossterm::execute!(
1075        std::io::stdout(),
1076        crossterm::event::DisableMouseCapture,
1077        crossterm::terminal::LeaveAlternateScreen,
1078        crossterm::cursor::Show
1079    );
1080
1081    let mut terminal = ratatui::init();
1082    terminal.clear()?;
1083    crossterm::terminal::enable_raw_mode()?;
1084    crossterm::execute!(
1085        std::io::stdout(),
1086        crossterm::terminal::EnterAlternateScreen,
1087        crossterm::event::EnableMouseCapture
1088    )?;
1089
1090    let runtime = Runtime::new();
1091    let result = runtime.run(runner, terminal).await;
1092    restore_terminal();
1093
1094    println!("tanu-tui terminated with {result:?}");
1095    result
1096}
1097
1098struct FpsCounter {
1099    frame_count: usize,
1100    last_second: std::time::Instant,
1101    fps: f64,
1102}
1103
1104impl FpsCounter {
1105    fn new() -> Self {
1106        Self {
1107            frame_count: 0,
1108            last_second: std::time::Instant::now(),
1109            fps: 0.0,
1110        }
1111    }
1112
1113    fn update(&mut self) {
1114        self.frame_count += 1;
1115        let now = std::time::Instant::now();
1116        let elapsed = now.duration_since(self.last_second).as_secs_f64();
1117
1118        if elapsed >= 1.0 {
1119            self.fps = self.frame_count as f64 / elapsed;
1120            self.frame_count = 0;
1121            self.last_second = now;
1122        }
1123    }
1124}