1use std::io::{self, Stdout};
15use std::time::{Duration, Instant};
16
17use crossterm::event::{Event, EventStream, KeyCode, KeyModifiers};
18use crossterm::execute;
19use crossterm::terminal::{EnterAlternateScreen, LeaveAlternateScreen};
20use futures::StreamExt;
21use ratatui::Terminal;
22use ratatui::backend::CrosstermBackend;
23use ratatui::layout::{Constraint, Layout};
24use ratatui::style::{Color, Modifier, Style};
25use ratatui::text::{Line, Span};
26use ratatui::widgets::{Paragraph, Scrollbar, ScrollbarOrientation, ScrollbarState};
27use tokio::sync::mpsc;
28
29use crate::interactive::WatchCommand;
30
31pub enum TuiMessage {
35 RunStarted {
37 total: usize,
38 workers: u32,
39 names: Vec<TestEntry>,
40 },
41 TestStarted { name: String },
43 StepUpdate {
45 test_name: String,
46 step_title: String,
47 status: EntryStatus,
48 duration_ms: Option<u64>,
49 },
50 TestFinished {
52 name: String,
53 status: EntryStatus,
54 duration: Duration,
55 error: Option<String>,
56 },
57 RunFinished {
59 passed: usize,
60 failed: usize,
61 skipped: usize,
62 flaky: usize,
63 duration: Duration,
64 },
65}
66
67#[derive(Clone)]
69pub struct TestEntry {
70 pub name: String,
71 pub status: EntryStatus,
72 pub duration: Option<Duration>,
73 pub steps: Vec<StepEntry>,
74 pub error: Option<String>,
75}
76
77#[derive(Clone)]
79pub struct StepEntry {
80 pub title: String,
81 pub status: EntryStatus,
82 pub duration_ms: Option<u64>,
83}
84
85#[derive(Clone, Copy, PartialEq, Eq)]
87pub enum EntryStatus {
88 Pending,
89 Running,
90 Passed,
91 Failed,
92 Skipped,
93 Flaky,
94}
95
96#[derive(Clone)]
98pub enum WatchStatus {
99 Idle,
100 Running {
101 completed: usize,
102 total: usize,
103 start: Instant,
104 },
105 Done {
106 passed: usize,
107 failed: usize,
108 skipped: usize,
109 flaky: usize,
110 duration: Duration,
111 },
112}
113
114#[derive(Debug, Clone, Copy, PartialEq, Eq)]
116pub enum DrainResult {
117 Completed,
118 Cancelled,
119 ChannelClosed,
120}
121
122const CLR_PASS: Color = Color::Green;
125const CLR_FAIL: Color = Color::Red;
126const CLR_RUN: Color = Color::Yellow;
127const CLR_FLAKY: Color = Color::Yellow;
128const CLR_DIM: Color = Color::DarkGray;
129const CLR_CYAN: Color = Color::Cyan;
130
131const ICON_PASS: &str = "\u{2713}"; const ICON_FAIL: &str = "\u{2717}"; const ICON_RUN: &str = "\u{25cf}"; const ICON_SKIP: &str = "\u{2212}"; const ICON_PEND: &str = "\u{25cb}"; const ICON_FLAKY: &str = "\u{25ce}"; pub struct WatchTui {
141 terminal: Terminal<CrosstermBackend<Stdout>>,
142 event_stream: EventStream,
143 msg_rx: mpsc::UnboundedReceiver<TuiMessage>,
144 status: WatchStatus,
145 entries: Vec<TestEntry>,
146 total_tests: usize,
147 num_workers: u32,
148 completed: usize,
149 run_start: Instant,
150 scroll_offset: usize,
151 total_content_lines: usize,
152 is_running: bool,
154 filter_input: Option<String>,
156 pub active_filter: Option<String>,
158}
159
160impl WatchTui {
161 pub fn new() -> Result<(Self, mpsc::UnboundedSender<TuiMessage>), String> {
162 crossterm::terminal::enable_raw_mode().map_err(|e| format!("enable raw mode: {e}"))?;
163 execute!(io::stdout(), EnterAlternateScreen).map_err(|e| format!("enter alternate screen: {e}"))?;
164
165 let backend = CrosstermBackend::new(io::stdout());
166 let terminal = Terminal::new(backend).map_err(|e| format!("create terminal: {e}"))?;
167
168 let (msg_tx, msg_rx) = mpsc::unbounded_channel();
169
170 let tui = Self {
171 terminal,
172 event_stream: EventStream::new(),
173 msg_rx,
174 status: WatchStatus::Idle,
175 entries: Vec::new(),
176 total_tests: 0,
177 num_workers: 0,
178 completed: 0,
179 run_start: Instant::now(),
180 scroll_offset: 0,
181 total_content_lines: 0,
182 is_running: false,
183 filter_input: None,
184 active_filter: None,
185 };
186
187 Ok((tui, msg_tx))
188 }
189
190 fn handle_message(&mut self, msg: TuiMessage) {
193 match msg {
194 TuiMessage::RunStarted { total, workers, names } => {
195 self.total_tests = total;
196 self.num_workers = workers;
197 self.completed = 0;
198 self.run_start = Instant::now();
199 self.scroll_offset = 0;
200 self.entries = names;
201 self.is_running = true;
202 self.status = WatchStatus::Running {
203 completed: 0,
204 total,
205 start: self.run_start,
206 };
207 self.render();
208 },
209 TuiMessage::TestStarted { name } => {
210 if let Some(entry) = self.entries.iter_mut().find(|e| e.name == name) {
211 entry.status = EntryStatus::Running;
212 entry.steps.clear();
213 entry.error = None;
214 } else {
215 self.entries.push(TestEntry {
216 name,
217 status: EntryStatus::Running,
218 duration: None,
219 steps: Vec::new(),
220 error: None,
221 });
222 }
223 self.auto_scroll_to_running();
224 self.render();
225 },
226 TuiMessage::StepUpdate {
227 test_name,
228 step_title,
229 status,
230 duration_ms,
231 } => {
232 if let Some(entry) = self.entries.iter_mut().find(|e| e.name == test_name) {
233 if let Some(step) = entry.steps.iter_mut().find(|s| s.title == step_title) {
234 step.status = status;
235 step.duration_ms = duration_ms;
236 } else {
237 entry.steps.push(StepEntry {
238 title: step_title,
239 status,
240 duration_ms,
241 });
242 }
243 }
244 self.render();
245 },
246 TuiMessage::TestFinished {
247 name,
248 status,
249 duration,
250 error,
251 } => {
252 self.completed += 1;
253 if let Some(entry) = self.entries.iter_mut().find(|e| e.name == name) {
254 entry.status = status;
255 entry.duration = Some(duration);
256 entry.error = error;
257 } else {
258 self.entries.push(TestEntry {
259 name,
260 status,
261 duration: Some(duration),
262 steps: Vec::new(),
263 error,
264 });
265 }
266 self.status = WatchStatus::Running {
267 completed: self.completed,
268 total: self.total_tests,
269 start: self.run_start,
270 };
271 self.render();
272 },
273 TuiMessage::RunFinished {
274 passed,
275 failed,
276 skipped,
277 flaky,
278 duration,
279 } => {
280 self.is_running = false;
281 self.status = WatchStatus::Done {
282 passed,
283 failed,
284 skipped,
285 flaky,
286 duration,
287 };
288 self.render();
289 },
290 }
291 }
292
293 fn body_height(&mut self) -> usize {
294 (self.terminal.get_frame().area().height as usize).saturating_sub(5)
296 }
297
298 fn auto_scroll_to_running(&mut self) {
301 let visible = self.body_height();
302 if visible == 0 {
303 return;
304 }
305
306 let mut target_line = 0usize;
307 let mut found = false;
308 for entry in &self.entries {
309 if entry.status == EntryStatus::Running {
310 found = true;
311 break;
312 }
313 target_line += 1 + entry.steps.len();
314 if entry.status == EntryStatus::Failed && entry.error.is_some() {
315 target_line += 1;
316 }
317 }
318 if !found {
319 return;
320 }
321
322 let viewport_end = self.scroll_offset + visible;
323 if target_line < self.scroll_offset {
324 self.scroll_offset = target_line;
325 } else if target_line >= viewport_end {
326 let context = visible / 3;
327 self.scroll_offset = target_line.saturating_sub(visible.saturating_sub(context));
328 }
329 }
330
331 fn scroll_by(&mut self, delta: isize, visible_height: usize) {
332 let max = self.total_content_lines.saturating_sub(visible_height);
333 if delta < 0 {
334 self.scroll_offset = self.scroll_offset.saturating_sub(delta.unsigned_abs());
335 } else {
336 self.scroll_offset = (self.scroll_offset + delta.unsigned_abs()).min(max);
337 }
338 }
339
340 fn build_content_lines(entries: &[TestEntry], width: usize) -> Vec<Line<'static>> {
343 let mut lines: Vec<Line<'static>> = Vec::new();
344
345 for entry in entries {
346 let (icon, icon_color) = status_icon(entry.status);
348 let name_style = match entry.status {
349 EntryStatus::Failed => Style::default().fg(CLR_FAIL),
350 EntryStatus::Skipped | EntryStatus::Pending => Style::default().fg(CLR_DIM),
351 EntryStatus::Running => Style::default().fg(Color::White),
352 _ => Style::default(), };
354
355 let mut spans = vec![
356 Span::raw(" "),
357 Span::styled(format!(" {icon} "), Style::default().fg(icon_color)),
358 Span::styled(entry.name.clone(), name_style),
359 ];
360
361 if let Some(dur) = entry.duration {
362 spans.push(Span::styled(
363 format!(" ({:.0}ms)", dur.as_millis()),
364 Style::default().fg(CLR_DIM),
365 ));
366 }
367
368 lines.push(Line::from(spans));
369
370 for step in &entry.steps {
372 let (sicon, sicon_color) = status_icon(step.status);
373 let step_name_style = match step.status {
374 EntryStatus::Failed => Style::default().fg(CLR_FAIL),
375 EntryStatus::Running => Style::default().fg(CLR_RUN),
376 _ => Style::default().fg(CLR_DIM),
377 };
378
379 let mut step_spans = vec![
380 Span::raw(" "),
381 Span::styled(format!("{sicon} "), Style::default().fg(sicon_color)),
382 Span::styled(step.title.clone(), step_name_style),
383 ];
384
385 if let Some(ms) = step.duration_ms {
386 step_spans.push(Span::styled(format!(" ({ms}ms)"), Style::default().fg(CLR_DIM)));
387 }
388
389 lines.push(Line::from(step_spans));
390 }
391
392 if entry.status == EntryStatus::Failed {
394 if let Some(ref err) = entry.error {
395 for err_line in err.lines().take(3) {
396 if !err_line.is_empty() {
397 lines.push(Line::from(vec![
398 Span::raw(" "),
399 Span::styled(
400 truncate_str(err_line, width.saturating_sub(8)),
401 Style::default().fg(CLR_FAIL).add_modifier(Modifier::DIM),
402 ),
403 ]));
404 }
405 }
406 }
407 }
408 }
409
410 lines
411 }
412
413 fn render(&mut self) {
416 let entries = self.entries.clone();
417 let status = self.status.clone();
418 let total_tests = self.total_tests;
419 let num_workers = self.num_workers;
420 let scroll_offset = self.scroll_offset;
421 let is_running = self.is_running;
422 let filter_input = self.filter_input.clone();
423 let active_filter = self.active_filter.clone();
424
425 let _ = self.terminal.draw(|frame| {
426 let area = frame.area();
427 let width = area.width as usize;
428
429 let [header_area, body_area, footer_area] =
431 Layout::vertical([Constraint::Length(2), Constraint::Min(1), Constraint::Length(3)]).areas(area);
432
433 let mut header_lines = render_header(&status, total_tests, num_workers);
435 if let Some(ref pattern) = active_filter {
437 header_lines[1] = Line::from(vec![
438 Span::raw(" "),
439 Span::styled("Filter: ", Style::default().fg(CLR_DIM)),
440 Span::styled(
441 pattern.clone(),
442 Style::default().fg(CLR_CYAN).add_modifier(Modifier::BOLD),
443 ),
444 ]);
445 }
446 frame.render_widget(Paragraph::new(header_lines), header_area);
447
448 let content_lines = Self::build_content_lines(&entries, width);
450 let total_lines = content_lines.len();
451
452 let paragraph = Paragraph::new(content_lines).scroll((scroll_offset as u16, 0));
453 frame.render_widget(paragraph, body_area);
454
455 if total_lines > body_area.height as usize {
457 let scrollbar = Scrollbar::new(ScrollbarOrientation::VerticalRight);
458 let max_scroll = total_lines.saturating_sub(body_area.height as usize);
459 let mut scrollbar_state = ScrollbarState::new(max_scroll).position(scroll_offset);
460 frame.render_stateful_widget(scrollbar, body_area, &mut scrollbar_state);
461 }
462
463 let [sep_area, status_area, hints_area] =
465 Layout::vertical([Constraint::Length(1), Constraint::Length(1), Constraint::Length(1)]).areas(footer_area);
466
467 frame.render_widget(
468 Paragraph::new(Line::styled("\u{2500}".repeat(width), Style::default().fg(CLR_DIM))),
469 sep_area,
470 );
471 frame.render_widget(Paragraph::new(render_status_line(&status, width)), status_area);
472
473 let hints_line = if let Some(ref input) = filter_input {
475 Line::from(vec![
476 Span::raw(" "),
477 Span::styled(
478 "Filter pattern: ",
479 Style::default().fg(CLR_CYAN).add_modifier(Modifier::BOLD),
480 ),
481 Span::styled(input.clone(), Style::default().fg(Color::White)),
482 Span::styled("\u{2588}", Style::default().fg(Color::White)), Span::styled(" (Enter to apply, Esc to cancel)", Style::default().fg(CLR_DIM)),
484 ])
485 } else {
486 render_hints(is_running, active_filter.is_some())
487 };
488 frame.render_widget(Paragraph::new(hints_line), hints_area);
489 });
490
491 let width = self.terminal.get_frame().area().width as usize;
493 self.total_content_lines = Self::build_content_lines(&self.entries, width).len();
494 }
495
496 pub fn set_status(&mut self, status: WatchStatus) {
497 self.status = status;
498 self.render();
499 }
500
501 pub fn flush(&mut self) {
502 while let Ok(msg) = self.msg_rx.try_recv() {
503 self.handle_message(msg);
504 }
505 }
506
507 pub async fn drain_while_running(&mut self) -> DrainResult {
508 loop {
509 tokio::select! {
510 msg = self.msg_rx.recv() => {
511 match msg {
512 Some(msg) => {
513 let is_done = matches!(&msg, TuiMessage::RunFinished { .. });
514 self.handle_message(msg);
515 if is_done { return DrainResult::Completed; }
516 }
517 None => return DrainResult::ChannelClosed,
518 }
519 }
520 event = self.event_stream.next() => {
521 let Some(Ok(event)) = event else { continue };
522 if let Event::Key(key) = event {
523 if key.modifiers.contains(KeyModifiers::CONTROL) && key.code == KeyCode::Char('c') {
524 return DrainResult::Cancelled;
525 }
526 if key.code == KeyCode::Char('q') {
527 return DrainResult::Cancelled;
528 }
529 let body_height = self.terminal.get_frame().area().height.saturating_sub(5) as usize;
530 match key.code {
531 KeyCode::Up | KeyCode::Char('k') => { self.scroll_by(-1, body_height); self.render(); }
532 KeyCode::Down | KeyCode::Char('j') => { self.scroll_by(1, body_height); self.render(); }
533 KeyCode::PageUp => { self.scroll_by(-isize::try_from(body_height).unwrap_or(isize::MAX), body_height); self.render(); }
534 KeyCode::PageDown => { self.scroll_by(isize::try_from(body_height).unwrap_or(isize::MAX), body_height); self.render(); }
535 _ => {}
536 }
537 } else if let Event::Resize(_, _) = event {
538 self.render();
539 }
540 }
541 }
542 }
543 }
544
545 pub async fn next_command(&mut self) -> Option<WatchCommand> {
546 loop {
547 tokio::select! {
548 msg = self.msg_rx.recv() => {
549 self.handle_message(msg?);
550 }
551 event = self.event_stream.next() => {
552 let Some(Ok(event)) = event else { return None };
553 if let Event::Key(key) = event {
554 if self.filter_input.is_some() {
556 match key.code {
557 KeyCode::Enter => {
558 let pattern = self.filter_input.take().unwrap_or_default();
559 if !pattern.is_empty() {
560 self.active_filter = Some(pattern.clone());
561 self.render();
562 return Some(WatchCommand::FilterByName(pattern));
563 }
564 self.render();
565 continue;
566 }
567 KeyCode::Esc => {
568 self.filter_input = None;
569 self.render();
570 continue;
571 }
572 KeyCode::Backspace => {
573 if let Some(ref mut input) = self.filter_input {
574 input.pop();
575 }
576 self.render();
577 continue;
578 }
579 KeyCode::Char(c) => {
580 if let Some(ref mut input) = self.filter_input {
581 input.push(c);
582 }
583 self.render();
584 continue;
585 }
586 _ => continue,
587 }
588 }
589
590 if key.modifiers.contains(KeyModifiers::CONTROL) && key.code == KeyCode::Char('c') {
593 return Some(WatchCommand::Quit);
594 }
595
596 let body_height = self.body_height();
598 match key.code {
599 KeyCode::Up | KeyCode::Char('k') => { self.scroll_by(-1, body_height); self.render(); continue; }
600 KeyCode::Down | KeyCode::Char('j') => { self.scroll_by(1, body_height); self.render(); continue; }
601 KeyCode::PageUp => { self.scroll_by(-isize::try_from(body_height).unwrap_or(isize::MAX), body_height); self.render(); continue; }
602 KeyCode::PageDown => { self.scroll_by(isize::try_from(body_height).unwrap_or(isize::MAX), body_height); self.render(); continue; }
603 _ => {}
604 }
605
606 match key.code {
608 KeyCode::Char('p') => {
609 self.filter_input = Some(String::new());
611 self.render();
612 continue;
613 }
614 KeyCode::Char('c') if self.active_filter.is_some() => {
615 self.active_filter = None;
617 self.render();
618 return Some(WatchCommand::RunAll);
619 }
620 _ => {}
621 }
622 if let Some(cmd) = map_key_event(key) { return Some(cmd); }
623 } else if let Event::Resize(_, _) = event {
624 self.render();
625 }
626 }
627 }
628 }
629 }
630
631 pub fn shutdown(&mut self) {
632 let _ = self.terminal.clear();
633 let _ = crossterm::terminal::disable_raw_mode();
634 let _ = execute!(io::stdout(), LeaveAlternateScreen);
635 }
636}
637
638impl Drop for WatchTui {
639 fn drop(&mut self) {
640 self.shutdown();
641 }
642}
643
644fn status_icon(status: EntryStatus) -> (&'static str, Color) {
648 match status {
649 EntryStatus::Pending => (ICON_PEND, CLR_DIM),
650 EntryStatus::Running => (ICON_RUN, CLR_RUN),
651 EntryStatus::Passed => (ICON_PASS, CLR_PASS),
652 EntryStatus::Failed => (ICON_FAIL, CLR_FAIL),
653 EntryStatus::Skipped => (ICON_SKIP, CLR_DIM),
654 EntryStatus::Flaky => (ICON_FLAKY, CLR_FLAKY),
655 }
656}
657
658fn render_header(status: &WatchStatus, total: usize, workers: u32) -> Vec<Line<'static>> {
660 match status {
661 WatchStatus::Idle => vec![
662 Line::from(vec![
663 Span::styled(
664 " WATCH ",
665 Style::default()
666 .fg(Color::Black)
667 .bg(CLR_CYAN)
668 .add_modifier(Modifier::BOLD),
669 ),
670 Span::styled(" Watching for changes...", Style::default().fg(CLR_DIM)),
671 ]),
672 Line::raw(""),
673 ],
674 WatchStatus::Running { .. } => vec![
675 Line::from(vec![
676 Span::styled(
677 " RUNS ",
678 Style::default()
679 .fg(Color::Black)
680 .bg(CLR_RUN)
681 .add_modifier(Modifier::BOLD),
682 ),
683 Span::raw(format!(" {total} test(s) with {workers} worker(s)")),
684 ]),
685 Line::raw(""),
686 ],
687 WatchStatus::Done {
688 passed,
689 failed,
690 skipped,
691 flaky,
692 ..
693 } => {
694 let badge = if *failed > 0 {
695 Span::styled(
696 " FAIL ",
697 Style::default()
698 .fg(Color::White)
699 .bg(CLR_FAIL)
700 .add_modifier(Modifier::BOLD),
701 )
702 } else {
703 Span::styled(
704 " PASS ",
705 Style::default()
706 .fg(Color::Black)
707 .bg(CLR_PASS)
708 .add_modifier(Modifier::BOLD),
709 )
710 };
711 let mut summary = vec![badge, Span::raw(" ")];
712 if *passed > 0 {
713 summary.push(Span::styled(
714 format!("{passed} passed"),
715 Style::default().fg(CLR_PASS).add_modifier(Modifier::BOLD),
716 ));
717 }
718 if *failed > 0 {
719 if *passed > 0 {
720 summary.push(Span::styled(", ", Style::default().fg(CLR_DIM)));
721 }
722 summary.push(Span::styled(
723 format!("{failed} failed"),
724 Style::default().fg(CLR_FAIL).add_modifier(Modifier::BOLD),
725 ));
726 }
727 if *flaky > 0 {
728 summary.push(Span::styled(", ", Style::default().fg(CLR_DIM)));
729 summary.push(Span::styled(format!("{flaky} flaky"), Style::default().fg(CLR_FLAKY)));
730 }
731 if *skipped > 0 {
732 summary.push(Span::styled(", ", Style::default().fg(CLR_DIM)));
733 summary.push(Span::styled(format!("{skipped} skipped"), Style::default().fg(CLR_DIM)));
734 }
735 let total = passed + failed + skipped;
736 summary.push(Span::styled(format!(" ({total} total)"), Style::default().fg(CLR_DIM)));
737 vec![Line::from(summary), Line::raw("")]
738 },
739 }
740}
741
742fn render_status_line(status: &WatchStatus, width: usize) -> Line<'static> {
744 match status {
745 WatchStatus::Idle => Line::from(vec![
746 Span::raw(" "),
747 Span::styled("Press ", Style::default().fg(CLR_DIM)),
748 Span::styled("a", Style::default().add_modifier(Modifier::BOLD)),
749 Span::styled(" to run all, ", Style::default().fg(CLR_DIM)),
750 Span::styled("q", Style::default().add_modifier(Modifier::BOLD)),
751 Span::styled(" to quit", Style::default().fg(CLR_DIM)),
752 ]),
753 WatchStatus::Running {
754 completed,
755 total,
756 start,
757 } => {
758 let elapsed = start.elapsed();
759 let pct = if *total > 0 {
760 (*completed as f64 / *total as f64) * 100.0
761 } else {
762 0.0
763 };
764 let bar_w = (width / 3).clamp(10, 40);
765 let filled = (*completed * bar_w) / (*total).max(1);
766 let empty = bar_w - filled;
767 Line::from(vec![
768 Span::raw(" "),
769 Span::styled("Tests ", Style::default().add_modifier(Modifier::BOLD)),
770 Span::styled(
771 format!("{completed}"),
772 Style::default().fg(CLR_PASS).add_modifier(Modifier::BOLD),
773 ),
774 Span::styled(format!("/{total} "), Style::default().fg(CLR_DIM)),
775 Span::styled("\u{2588}".repeat(filled), Style::default().fg(CLR_PASS)),
776 Span::styled("\u{2591}".repeat(empty), Style::default().fg(CLR_DIM)),
777 Span::styled(format!(" {:.0}%", pct), Style::default().fg(CLR_DIM)),
778 Span::styled(format!(" {:.1}s", elapsed.as_secs_f64()), Style::default().fg(CLR_DIM)),
779 ])
780 },
781 WatchStatus::Done { duration, .. } => Line::from(vec![
782 Span::raw(" "),
783 Span::styled("Time ", Style::default().add_modifier(Modifier::BOLD)),
784 Span::styled(format!("{:.2}s", duration.as_secs_f64()), Style::default().fg(CLR_DIM)),
785 ]),
786 }
787}
788
789fn render_hints(is_running: bool, has_filter: bool) -> Line<'static> {
791 if is_running {
792 Line::from(vec![
793 Span::raw(" "),
794 Span::styled("q", Style::default().add_modifier(Modifier::BOLD)),
795 Span::styled(" cancel", Style::default().fg(CLR_DIM)),
796 Span::styled(" ", Style::default().fg(CLR_DIM)),
797 Span::styled("j/k", Style::default().add_modifier(Modifier::BOLD)),
798 Span::styled(" scroll", Style::default().fg(CLR_DIM)),
799 ])
800 } else {
801 let mut spans = vec![
802 Span::raw(" "),
803 Span::styled("a", Style::default().add_modifier(Modifier::BOLD)),
804 Span::styled(" run all", Style::default().fg(CLR_DIM)),
805 Span::styled(" ", Style::default().fg(CLR_DIM)),
806 Span::styled("f", Style::default().add_modifier(Modifier::BOLD)),
807 Span::styled(" failed", Style::default().fg(CLR_DIM)),
808 Span::styled(" ", Style::default().fg(CLR_DIM)),
809 Span::styled("p", Style::default().add_modifier(Modifier::BOLD)),
810 Span::styled(" filter", Style::default().fg(CLR_DIM)),
811 ];
812 if has_filter {
813 spans.extend([
814 Span::styled(" ", Style::default().fg(CLR_DIM)),
815 Span::styled("c", Style::default().add_modifier(Modifier::BOLD)),
816 Span::styled(" clear filter", Style::default().fg(CLR_DIM)),
817 ]);
818 }
819 spans.extend([
820 Span::styled(" ", Style::default().fg(CLR_DIM)),
821 Span::styled("j/k", Style::default().add_modifier(Modifier::BOLD)),
822 Span::styled(" scroll", Style::default().fg(CLR_DIM)),
823 Span::styled(" ", Style::default().fg(CLR_DIM)),
824 Span::styled("q", Style::default().add_modifier(Modifier::BOLD)),
825 Span::styled(" quit", Style::default().fg(CLR_DIM)),
826 ]);
827 Line::from(spans)
828 }
829}
830
831fn truncate_str(s: &str, max_len: usize) -> String {
832 if s.len() <= max_len {
833 s.to_string()
834 } else {
835 format!("{}...", &s[..max_len.saturating_sub(3)])
836 }
837}
838
839fn map_key_event(key: crossterm::event::KeyEvent) -> Option<WatchCommand> {
840 if key.modifiers.contains(KeyModifiers::CONTROL) && key.code == KeyCode::Char('c') {
841 return Some(WatchCommand::Quit);
842 }
843 match key.code {
844 KeyCode::Char('a') => Some(WatchCommand::RunAll),
845 KeyCode::Char('f') => Some(WatchCommand::RunFailed),
846 KeyCode::Char('q') => Some(WatchCommand::Quit),
847 KeyCode::Enter => Some(WatchCommand::Rerun),
848 _ => None,
850 }
851}