Skip to main content

micromux_tui/
lib.rs

1//! `micromux-tui` provides the terminal user interface for micromux.
2//!
3//! The main entry point is [`App`]. Most consumers construct an [`App`] from a list of
4//! [`micromux::ServiceDescriptor`] values, then call [`App::render`].
5
6mod event;
7mod reducer;
8mod render;
9mod state;
10mod style;
11
12/// Re-export of `crossterm` for consumers that need to share types with the TUI.
13pub use crossterm;
14/// Re-export of `ratatui` for consumers that need to share types with the TUI.
15pub use ratatui;
16
17use color_eyre::eyre;
18use futures::StreamExt;
19use micromux::{BoundedLog, Command, Event as SchedulerEvent, ServiceDescriptor};
20use ratatui::DefaultTerminal;
21use tokio::sync::mpsc;
22use tokio_stream::wrappers::ReceiverStream;
23
24type UiEventStream = futures::stream::Chain<
25    ReceiverStream<SchedulerEvent>,
26    futures::stream::Pending<SchedulerEvent>,
27>;
28
29const KIB: usize = 1024;
30const MIB: usize = 1024 * KIB;
31const HEALTHCHECK_HISTORY: usize = 2;
32
33#[derive()]
34/// Terminal application state.
35pub struct App {
36    /// Running state of the TUI application.
37    running: bool,
38    commands_tx: mpsc::Sender<Command>,
39    shutdown: micromux::CancellationToken,
40    ui_rx: UiEventStream,
41    /// Event handler
42    input_event_handler: event::InputHandler,
43    /// Current state
44    state: state::State,
45    /// Log viewer
46    log_view: crate::render::log_view::LogView,
47    healthcheck_view: crate::render::log_view::LogView,
48    show_healthcheck_pane: bool,
49    attach_mode: bool,
50    focus: Focus,
51    terminal_cols: u16,
52    terminal_rows: u16,
53    last_pty_cols: u16,
54    last_pty_rows: u16,
55}
56
57#[derive(Debug, Clone, Copy, PartialEq, Eq)]
58enum Focus {
59    /// Service list is focused.
60    Services,
61    /// Log pane is focused.
62    Logs,
63    /// Healthcheck pane is focused.
64    Healthcheck,
65}
66
67impl App {
68    /// Construct a new [`App`].
69    #[must_use]
70    pub fn new(
71        services: &[ServiceDescriptor],
72        ui_rx: mpsc::Receiver<SchedulerEvent>,
73        commands_tx: mpsc::Sender<Command>,
74        shutdown: micromux::CancellationToken,
75    ) -> Self {
76        let ui_rx = ReceiverStream::new(ui_rx).chain(futures::stream::pending());
77
78        let services = services
79            .iter()
80            .map(|service| {
81                let service_state = state::Service {
82                    id: service.id.clone(),
83                    exec_state: state::Execution::Pending,
84                    open_ports: service.open_ports.clone(),
85                    logs: BoundedLog::with_limits(1000, 64 * MIB).into(),
86                    cached_num_lines: 0,
87                    cached_logs: String::new(),
88                    logs_dirty: true,
89                    healthcheck_configured: service.healthcheck_configured,
90                    healthcheck_attempts: std::collections::VecDeque::new(),
91                    healthcheck_cached_num_lines: 0,
92                    healthcheck_cached_text: String::new(),
93                    healthcheck_dirty: true,
94                };
95                (service.id.clone(), service_state)
96            })
97            .collect();
98
99        let log_view = render::log_view::LogView::default();
100        let healthcheck_view = render::log_view::LogView::default();
101
102        Self {
103            running: true,
104            commands_tx,
105            shutdown,
106            ui_rx,
107            input_event_handler: event::InputHandler::new(),
108            state: state::State::new(services),
109            log_view,
110            healthcheck_view,
111            show_healthcheck_pane: false,
112            attach_mode: false,
113            focus: Focus::Services,
114            terminal_cols: 80,
115            terminal_rows: 24,
116            last_pty_cols: 0,
117            last_pty_rows: 0,
118        }
119    }
120}
121
122impl App {
123    fn desired_pty_size(&self) -> (u16, u16) {
124        use ratatui::layout::{Constraint, Direction, Layout, Rect};
125
126        let area = Rect {
127            x: 0,
128            y: 0,
129            width: self.terminal_cols,
130            height: self.terminal_rows,
131        };
132
133        let [_header_area, main_area, _footer_area] = Layout::default()
134            .direction(Direction::Vertical)
135            .constraints([
136                Constraint::Length(0),
137                Constraint::Min(0),
138                Constraint::Length(1),
139            ])
140            .spacing(0)
141            .areas(area);
142
143        let [_services_area, main_right_area] = Layout::default()
144            .direction(Direction::Horizontal)
145            .constraints([
146                Constraint::Length(self.state.services_sidebar_width),
147                Constraint::Min(0),
148            ])
149            .spacing(0)
150            .areas(main_area);
151
152        let logs_area = if self.show_healthcheck_pane {
153            let [a, _b] = Layout::default()
154                .direction(Direction::Horizontal)
155                .constraints([Constraint::Percentage(50), Constraint::Percentage(50)])
156                .spacing(0)
157                .areas::<2>(main_right_area);
158            a
159        } else {
160            main_right_area
161        };
162
163        let [logs_pane_area, _scrollbar_area] = Layout::default()
164            .direction(Direction::Horizontal)
165            .constraints([Constraint::Min(0), Constraint::Length(1)])
166            .spacing(0)
167            .areas(logs_area);
168
169        let cols = logs_pane_area.width.saturating_sub(2).max(1);
170        let rows = logs_pane_area.height.saturating_sub(2).max(1);
171        (cols, rows)
172    }
173
174    fn maybe_resize_pty(&mut self) {
175        let (cols, rows) = self.desired_pty_size();
176        if cols == self.last_pty_cols && rows == self.last_pty_rows {
177            return;
178        }
179
180        self.last_pty_cols = cols;
181        self.last_pty_rows = rows;
182        let _ = self.commands_tx.try_send(Command::ResizeAll { cols, rows });
183    }
184
185    /// Run the TUI event loop.
186    ///
187    /// # Errors
188    ///
189    /// Returns an error if:
190    /// - Receiving an input event fails.
191    /// - The underlying terminal backend fails to draw.
192    pub async fn run(mut self, mut terminal: DefaultTerminal) -> eyre::Result<()> {
193        #[derive(Debug, strum::Display)]
194        enum Event {
195            Input(event::Input),
196            Scheduler(SchedulerEvent),
197        }
198
199        let area = terminal.size()?;
200        self.terminal_cols = area.width;
201        self.terminal_rows = area.height;
202        self.maybe_resize_pty();
203
204        while self.is_running() {
205            // Wait until an (input) event is received.
206            let event = tokio::select! {
207                () = self.shutdown.cancelled() => None,
208                input = self.input_event_handler.next() => Some(Event::Input(input?)),
209                event = self.ui_rx.next() => event.map(Event::Scheduler),
210            };
211
212            match &event {
213                Some(Event::Input(event)) if !event.is_tick() => {
214                    tracing::trace!(%event, "received event");
215                }
216                Some(Event::Scheduler(event)) => {
217                    tracing::debug!(%event, "received event");
218                }
219                _ => {}
220            }
221
222            match event {
223                Some(Event::Input(event)) => {
224                    self.handle_input_event(event);
225                    terminal.draw(|frame| frame.render_widget(&mut self, frame.area()))?;
226                }
227                Some(Event::Scheduler(event)) => {
228                    self.handle_event(event);
229                    terminal.draw(|frame| frame.render_widget(&mut self, frame.area()))?;
230                }
231                None => {
232                    self.running = false;
233                }
234            }
235        }
236        Ok(())
237    }
238
239    fn handle_event(&mut self, event: SchedulerEvent) {
240        reducer::apply(&mut self.state, event);
241    }
242
243    fn handle_input_event(&mut self, input_event: event::Input) {
244        match input_event {
245            event::Input::Tick => self.tick(),
246            event::Input::Event(event) => self.handle_crossterm_event(&event),
247        }
248    }
249
250    fn handle_crossterm_event(&mut self, event: &crossterm::event::Event) {
251        use crossterm::event::KeyEventKind;
252
253        match *event {
254            crossterm::event::Event::Resize(cols, rows) => {
255                self.terminal_cols = cols;
256                self.terminal_rows = rows;
257                self.maybe_resize_pty();
258            }
259            crossterm::event::Event::Key(key) if key.kind == KeyEventKind::Press => {
260                self.handle_key_press(key);
261            }
262            _ => {}
263        }
264    }
265
266    fn handle_key_press(&mut self, key: crossterm::event::KeyEvent) {
267        use crossterm::event::KeyCode;
268
269        if self.attach_mode {
270            self.handle_key_press_attach_mode(key);
271            return;
272        }
273
274        match key.code {
275            // Quit
276            KeyCode::Char('q') => self.exit(),
277
278            // Toggle focus
279            KeyCode::Tab => self.toggle_focus(),
280
281            // Toggle attach mode
282            KeyCode::Char('a') => {
283                self.attach_mode = !self.attach_mode;
284            }
285            KeyCode::Char('H') => self.toggle_healthcheck_pane(),
286
287            // Disable current service
288            KeyCode::Char('d') => self.disable_current_service(),
289
290            // Restart service
291            KeyCode::Char('r') => self.restart_current_service(),
292
293            // Restart all services
294            KeyCode::Char('R') => self.restart_all_services(),
295
296            // Navigation
297            KeyCode::Char('k') | KeyCode::Up => self.navigate_up(),
298            KeyCode::Char('j') | KeyCode::Down => self.navigate_down(),
299            KeyCode::Char('g') => self.scroll_to_top(),
300            KeyCode::Char('G') => self.scroll_to_bottom(),
301
302            // Decrease service sidebar width (resize to the left)
303            KeyCode::Char('-' | 'h') | KeyCode::Left => {
304                self.state.resize_left();
305                self.maybe_resize_pty();
306            }
307
308            // Increase service sidebar width (resize to the right)
309            KeyCode::Char('+' | 'l') | KeyCode::Right => {
310                self.state.resize_right();
311                self.maybe_resize_pty();
312            }
313
314            // Toggle wrapping for log viewer
315            KeyCode::Char('w') => self.toggle_wrap(),
316
317            // Toggle automatic tailing for log viewer
318            KeyCode::Char('t') => self.toggle_tail(),
319            _ => {}
320        }
321    }
322
323    fn handle_key_press_attach_mode(&mut self, key: crossterm::event::KeyEvent) {
324        use crossterm::event::{KeyCode, KeyModifiers};
325
326        match key.code {
327            KeyCode::Esc if key.modifiers.contains(KeyModifiers::ALT) => {
328                self.attach_mode = false;
329            }
330            _ => {
331                if let Some(bytes) = key_event_to_bytes(key.code, key.modifiers)
332                    && let Some(service) = self.state.current_service()
333                {
334                    let service_id = service.id.clone();
335                    let _ = self
336                        .commands_tx
337                        .try_send(Command::SendInput(service_id, bytes));
338                }
339            }
340        }
341    }
342
343    fn toggle_focus(&mut self) {
344        self.focus = if self.show_healthcheck_pane {
345            match self.focus {
346                Focus::Services => Focus::Logs,
347                Focus::Logs => Focus::Healthcheck,
348                Focus::Healthcheck => Focus::Services,
349            }
350        } else {
351            match self.focus {
352                Focus::Services => Focus::Logs,
353                Focus::Logs | Focus::Healthcheck => Focus::Services,
354            }
355        };
356    }
357
358    fn toggle_healthcheck_pane(&mut self) {
359        self.show_healthcheck_pane = !self.show_healthcheck_pane;
360        if !self.show_healthcheck_pane && self.focus == Focus::Healthcheck {
361            self.focus = Focus::Logs;
362        }
363        self.maybe_resize_pty();
364    }
365
366    fn navigate_up(&mut self) {
367        match self.focus {
368            Focus::Services => self.state.service_up(),
369            Focus::Logs => self.scroll_logs_up(1),
370            Focus::Healthcheck => self.scroll_healthchecks_up(1),
371        }
372    }
373
374    fn navigate_down(&mut self) {
375        match self.focus {
376            Focus::Services => self.state.service_down(),
377            Focus::Logs => self.scroll_logs_down(1),
378            Focus::Healthcheck => self.scroll_healthchecks_down(1),
379        }
380    }
381
382    fn scroll_to_top(&mut self) {
383        match self.focus {
384            Focus::Logs => {
385                self.log_view.follow_tail = false;
386                self.log_view.scroll_offset = 0;
387            }
388            Focus::Healthcheck => {
389                self.healthcheck_view.follow_tail = false;
390                self.healthcheck_view.scroll_offset = 0;
391            }
392            Focus::Services => {}
393        }
394    }
395
396    fn scroll_to_bottom(&mut self) {
397        match self.focus {
398            Focus::Logs => {
399                self.log_view.follow_tail = true;
400            }
401            Focus::Healthcheck => {
402                self.healthcheck_view.follow_tail = true;
403            }
404            Focus::Services => {}
405        }
406    }
407
408    fn toggle_wrap(&mut self) {
409        let wrap = !self.log_view.wrap;
410        self.log_view.wrap = wrap;
411        self.healthcheck_view.wrap = wrap;
412    }
413
414    fn toggle_tail(&mut self) {
415        match self.focus {
416            Focus::Logs | Focus::Services => {
417                self.log_view.follow_tail = !self.log_view.follow_tail;
418            }
419            Focus::Healthcheck => {
420                self.healthcheck_view.follow_tail = !self.healthcheck_view.follow_tail;
421            }
422        }
423    }
424
425    fn log_viewport_height(&self) -> u16 {
426        // total rows minus footer (1) minus logs block borders (2)
427        self.terminal_rows.saturating_sub(3)
428    }
429
430    fn scroll_logs_up(&mut self, lines: u16) {
431        self.log_view.follow_tail = false;
432        self.log_view.scroll_offset = self.log_view.scroll_offset.saturating_sub(lines);
433    }
434
435    fn scroll_logs_down(&mut self, lines: u16) {
436        self.log_view.follow_tail = false;
437        let Some(service) = self.state.current_service() else {
438            return;
439        };
440        let (num_lines, _) = service.logs.full_text();
441        let viewport = self.log_viewport_height();
442        let max_off = num_lines.saturating_sub(viewport);
443        self.log_view.scroll_offset = self
444            .log_view
445            .scroll_offset
446            .saturating_add(lines)
447            .min(max_off);
448    }
449
450    fn scroll_healthchecks_up(&mut self, lines: u16) {
451        self.healthcheck_view.follow_tail = false;
452        self.healthcheck_view.scroll_offset =
453            self.healthcheck_view.scroll_offset.saturating_sub(lines);
454    }
455
456    fn scroll_healthchecks_down(&mut self, lines: u16) {
457        self.healthcheck_view.follow_tail = false;
458        let Some(service) = self.state.current_service() else {
459            return;
460        };
461        let num_lines = service.healthcheck_cached_num_lines;
462        let viewport = self.log_viewport_height();
463        let max_off = num_lines.saturating_sub(viewport);
464        self.healthcheck_view.scroll_offset = self
465            .healthcheck_view
466            .scroll_offset
467            .saturating_add(lines)
468            .min(max_off);
469    }
470
471    /// Handles the tick event of the terminal.
472    pub fn tick(&self) {}
473
474    fn is_running(&self) -> bool {
475        self.running
476    }
477
478    fn exit(&mut self) {
479        // Send shutdown (cancellation) signal
480        self.shutdown.cancel();
481        self.running = false;
482    }
483
484    /// Disable service
485    fn disable_current_service(&self) {
486        let Some(service) = self.state.current_service() else {
487            return;
488        };
489        tracing::info!(service_id = service.id, "disabling service");
490        let command = match service.exec_state {
491            state::Execution::Disabled => Command::Enable(service.id.clone()),
492            _ => Command::Disable(service.id.clone()),
493        };
494        let _ = self.commands_tx.try_send(command);
495    }
496
497    /// Restart service
498    fn restart_current_service(&self) {
499        let Some(service) = self.state.current_service() else {
500            return;
501        };
502        tracing::info!(service_id = service.id, "restarting service");
503        if service.exec_state == state::Execution::Disabled {
504            let _ = self
505                .commands_tx
506                .try_send(Command::Enable(service.id.clone()));
507        }
508        let _ = self
509            .commands_tx
510            .try_send(Command::Restart(service.id.clone()));
511    }
512
513    /// Restart all services
514    fn restart_all_services(&self) {
515        tracing::info!("restarting all services");
516        let _ = self.commands_tx.try_send(Command::RestartAll);
517    }
518}
519
520fn key_event_to_bytes(
521    code: crossterm::event::KeyCode,
522    modifiers: crossterm::event::KeyModifiers,
523) -> Option<Vec<u8>> {
524    use crossterm::event::KeyCode;
525
526    if modifiers.contains(crossterm::event::KeyModifiers::ALT)
527        && !modifiers.contains(crossterm::event::KeyModifiers::CONTROL)
528        && !matches!(code, KeyCode::Esc)
529    {
530        let base_modifiers = modifiers - crossterm::event::KeyModifiers::ALT;
531        if let Some(mut bytes) = key_event_to_bytes(code, base_modifiers) {
532            let mut out = Vec::with_capacity(1 + bytes.len());
533            out.push(0x1b);
534            out.append(&mut bytes);
535            return Some(out);
536        }
537    }
538
539    if modifiers.contains(crossterm::event::KeyModifiers::CONTROL) {
540        match code {
541            KeyCode::Char(c) => {
542                let c = c.to_ascii_lowercase();
543                if c.is_ascii_lowercase() {
544                    return Some(vec![(c as u8) - b'a' + 1]);
545                }
546                match c {
547                    '@' => return Some(vec![0x00]),
548                    '[' => return Some(vec![0x1b]),
549                    '\\' => return Some(vec![0x1c]),
550                    ']' => return Some(vec![0x1d]),
551                    '^' => return Some(vec![0x1e]),
552                    '_' => return Some(vec![0x1f]),
553                    _ => {}
554                }
555            }
556            KeyCode::Enter => return Some(vec![b'\n']),
557            _ => {}
558        }
559    }
560
561    match code {
562        KeyCode::Esc => Some(vec![0x1b]),
563        KeyCode::Enter => Some(vec![b'\r']),
564        KeyCode::Tab => Some(vec![b'\t']),
565        KeyCode::BackTab => Some(b"\x1b[Z".to_vec()),
566        KeyCode::Backspace => Some(vec![0x7f]),
567        KeyCode::Char(c) => Some(c.to_string().into_bytes()),
568        KeyCode::Up => Some(b"\x1b[A".to_vec()),
569        KeyCode::Down => Some(b"\x1b[B".to_vec()),
570        KeyCode::Right => Some(b"\x1b[C".to_vec()),
571        KeyCode::Left => Some(b"\x1b[D".to_vec()),
572        KeyCode::Home => Some(b"\x1b[H".to_vec()),
573        KeyCode::End => Some(b"\x1b[F".to_vec()),
574        KeyCode::PageUp => Some(b"\x1b[5~".to_vec()),
575        KeyCode::PageDown => Some(b"\x1b[6~".to_vec()),
576        KeyCode::Delete => Some(b"\x1b[3~".to_vec()),
577        _ => None,
578    }
579}
580
581#[cfg(test)]
582mod tests {
583    use super::*;
584    use codespan_reporting::diagnostic::Diagnostic;
585    use crossterm::event::{KeyCode, KeyEvent, KeyEventKind, KeyEventState, KeyModifiers};
586    use indoc::indoc;
587    use std::path::Path;
588    use tokio::sync::mpsc;
589
590    fn tab_press() -> crate::event::Input {
591        crate::event::Input::Event(crossterm::event::Event::Key(KeyEvent {
592            code: KeyCode::Tab,
593            modifiers: KeyModifiers::NONE,
594            kind: KeyEventKind::Press,
595            state: KeyEventState::NONE,
596        }))
597    }
598
599    #[test]
600    fn ctrl_letters_map_to_ascii_control_codes() {
601        assert_eq!(
602            key_event_to_bytes(KeyCode::Char('a'), KeyModifiers::CONTROL),
603            Some(vec![0x01])
604        );
605        assert_eq!(
606            key_event_to_bytes(KeyCode::Char('z'), KeyModifiers::CONTROL),
607            Some(vec![0x1a])
608        );
609    }
610
611    #[test]
612    fn ctrl_specials_map_to_standard_codes() {
613        assert_eq!(
614            key_event_to_bytes(KeyCode::Char('@'), KeyModifiers::CONTROL),
615            Some(vec![0x00])
616        );
617        assert_eq!(
618            key_event_to_bytes(KeyCode::Char('['), KeyModifiers::CONTROL),
619            Some(vec![0x1b])
620        );
621        assert_eq!(
622            key_event_to_bytes(KeyCode::Char('\\'), KeyModifiers::CONTROL),
623            Some(vec![0x1c])
624        );
625        assert_eq!(
626            key_event_to_bytes(KeyCode::Char(']'), KeyModifiers::CONTROL),
627            Some(vec![0x1d])
628        );
629        assert_eq!(
630            key_event_to_bytes(KeyCode::Char('^'), KeyModifiers::CONTROL),
631            Some(vec![0x1e])
632        );
633        assert_eq!(
634            key_event_to_bytes(KeyCode::Char('_'), KeyModifiers::CONTROL),
635            Some(vec![0x1f])
636        );
637    }
638
639    #[test]
640    fn special_keys_encode_as_expected() {
641        assert_eq!(
642            key_event_to_bytes(KeyCode::Esc, KeyModifiers::NONE),
643            Some(vec![0x1b])
644        );
645        assert_eq!(
646            key_event_to_bytes(KeyCode::Enter, KeyModifiers::NONE),
647            Some(vec![b'\r'])
648        );
649        assert_eq!(
650            key_event_to_bytes(KeyCode::Enter, KeyModifiers::CONTROL),
651            Some(vec![b'\n'])
652        );
653        assert_eq!(
654            key_event_to_bytes(KeyCode::Tab, KeyModifiers::NONE),
655            Some(vec![b'\t'])
656        );
657        assert_eq!(
658            key_event_to_bytes(KeyCode::BackTab, KeyModifiers::NONE),
659            Some(b"\x1b[Z".to_vec())
660        );
661        assert_eq!(
662            key_event_to_bytes(KeyCode::Backspace, KeyModifiers::NONE),
663            Some(vec![0x7f])
664        );
665        assert_eq!(
666            key_event_to_bytes(KeyCode::Up, KeyModifiers::NONE),
667            Some(b"\x1b[A".to_vec())
668        );
669        assert_eq!(
670            key_event_to_bytes(KeyCode::Delete, KeyModifiers::NONE),
671            Some(b"\x1b[3~".to_vec())
672        );
673        assert_eq!(
674            key_event_to_bytes(KeyCode::Home, KeyModifiers::NONE),
675            Some(b"\x1b[H".to_vec())
676        );
677        assert_eq!(
678            key_event_to_bytes(KeyCode::End, KeyModifiers::NONE),
679            Some(b"\x1b[F".to_vec())
680        );
681    }
682
683    #[test]
684    fn alt_char_is_esc_prefixed() {
685        assert_eq!(
686            key_event_to_bytes(KeyCode::Char('x'), KeyModifiers::ALT),
687            Some(vec![0x1b, b'x'])
688        );
689    }
690
691    #[tokio::test]
692    async fn tab_cycles_focus_with_and_without_healthcheck_pane() -> color_eyre::eyre::Result<()> {
693        let yaml = indoc! {r#"
694            version: 1
695            services:
696              svc:
697                command: ["sh", "-c", "true"]
698        "#};
699        let mut diagnostics: Vec<Diagnostic<usize>> = vec![];
700        let parsed = micromux::from_str(yaml, Path::new("."), 0usize, None, &mut diagnostics)
701            .map_err(|err| color_eyre::eyre::eyre!(err.to_string()))?;
702        let mux = micromux::Micromux::new(&parsed)
703            .map_err(|err| color_eyre::eyre::eyre!(err.to_string()))?;
704        let services = mux.services();
705
706        let (_ui_tx, ui_rx) = mpsc::channel(1);
707        let (cmd_tx, _cmd_rx) = mpsc::channel(1);
708        let shutdown = micromux::CancellationToken::new();
709
710        let mut app = App::new(&services, ui_rx, cmd_tx, shutdown);
711        app.focus = Focus::Services;
712        app.show_healthcheck_pane = false;
713
714        app.handle_input_event(tab_press());
715        assert_eq!(app.focus, Focus::Logs);
716        app.handle_input_event(tab_press());
717        assert_eq!(app.focus, Focus::Services);
718
719        app.show_healthcheck_pane = true;
720        app.focus = Focus::Services;
721        app.handle_input_event(tab_press());
722        assert_eq!(app.focus, Focus::Logs);
723        app.handle_input_event(tab_press());
724        assert_eq!(app.focus, Focus::Healthcheck);
725        app.handle_input_event(tab_press());
726        assert_eq!(app.focus, Focus::Services);
727
728        Ok(())
729    }
730}