1mod 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#[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 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#[derive(Debug, Clone, Copy)]
96enum Execution {
97 One,
99 All,
101}
102
103#[derive(Debug, Clone, Copy)]
105enum CursorMovement {
106 Up,
108 Down,
110 UpHalfScreen,
112 DownHalfScreen,
114 Home,
116 End,
118}
119
120#[derive(Debug, Clone, Copy)]
122enum TabMovement {
123 Next,
125 Prev,
127}
128
129struct Model {
131 maximizing: bool,
133 current_pane: Pane,
135 current_exec: Option<Execution>,
137 test_cases_list: TestListState,
139 test_results: Vec<TestResult>,
141 info_state: InfoState,
143 logger_state: TuiWidgetState,
145 click: Option<crossterm::event::MouseEvent>,
147 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
204fn 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
217fn offset_end(_model: &mut Model) {
219 }
221
222fn 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
235fn 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
359fn 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), Constraint::Length(13), Constraint::Length(12), Constraint::Length(8), Constraint::Length(16), Constraint::Length(15), Constraint::Length(14), Constraint::Length(26), Constraint::Length(10), Constraint::Length(9), Constraint::Length(15), ])
416 .split(layout_menu);
417
418 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 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() } 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 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 };
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 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 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 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
701struct 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 }
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 }
822 },
823 runner::Event {project: _, module: _, test: _, body: EventBody::Summary(_summary)} => {
824 },
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 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 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 None
952 }
953 }
954 }
955}
956
957static 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
964fn 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
978fn 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
988pub 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 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}