mecomp_tui/ui/
app.rs

1//! Handles the main application view logic and state.
2//!
3//! The `App` struct is responsible for rendering the state of the application to the terminal.
4//! The app is updated every tick, and they use the state stores to get the latest state.
5
6use crossterm::event::{KeyCode, KeyEvent, KeyEventKind};
7use ratatui::{
8    Frame,
9    layout::{Constraint, Direction, Layout, Margin, Position, Rect},
10    style::{Style, Stylize},
11    text::Span,
12    widgets::Block,
13};
14use tokio::sync::mpsc::UnboundedSender;
15
16use crate::state::{
17    action::{Action, ComponentAction, GeneralAction, LibraryAction},
18    component::ActiveComponent,
19};
20
21use super::{
22    AppState,
23    colors::{APP_BORDER, APP_BORDER_TEXT, TEXT_NORMAL},
24    components::{
25        Component, ComponentRender, RenderProps, content_view::ContentView,
26        control_panel::ControlPanel, queuebar::QueueBar, sidebar::Sidebar,
27    },
28    widgets::popups::Popup,
29};
30
31#[must_use]
32pub struct App {
33    /// Action Sender
34    pub action_tx: UnboundedSender<Action>,
35    /// active component
36    active_component: ActiveComponent,
37    // Components that are always in view
38    sidebar: Sidebar,
39    queuebar: QueueBar,
40    control_panel: ControlPanel,
41    content_view: ContentView,
42    // (global) Components that are conditionally in view (popups)
43    popup: Option<Box<dyn Popup>>,
44}
45
46impl App {
47    fn get_active_view_component(&self) -> &dyn Component {
48        match self.active_component {
49            ActiveComponent::Sidebar => &self.sidebar,
50            ActiveComponent::QueueBar => &self.queuebar,
51            ActiveComponent::ControlPanel => &self.control_panel,
52            ActiveComponent::ContentView => &self.content_view,
53        }
54    }
55
56    fn get_active_view_component_mut(&mut self) -> &mut dyn Component {
57        match self.active_component {
58            ActiveComponent::Sidebar => &mut self.sidebar,
59            ActiveComponent::QueueBar => &mut self.queuebar,
60            ActiveComponent::ControlPanel => &mut self.control_panel,
61            ActiveComponent::ContentView => &mut self.content_view,
62        }
63    }
64
65    /// Move the app with the given state, but only update components that need to be updated.
66    ///
67    /// in this case, that is the search view
68    pub fn move_with_search(self, state: &AppState) -> Self {
69        let new = self.content_view.search_view.move_with_state(state);
70        Self {
71            content_view: ContentView {
72                search_view: new,
73                ..self.content_view
74            },
75            ..self
76        }
77    }
78
79    /// Move the app with the given state, but only update components that need to be updated.
80    ///
81    /// in this case, that is the queuebar, and the control panel
82    pub fn move_with_audio(self, state: &AppState) -> Self {
83        Self {
84            queuebar: self.queuebar.move_with_state(state),
85            control_panel: self.control_panel.move_with_state(state),
86            ..self
87        }
88    }
89
90    /// Move the app with the given state, but only update components that need to be updated.
91    ///
92    /// in this case, that is the content view
93    pub fn move_with_library(self, state: &AppState) -> Self {
94        let content_view = self.content_view.move_with_state(state);
95        Self {
96            content_view,
97            ..self
98        }
99    }
100
101    /// Move the app with the given state, but only update components that need to be updated.
102    ///
103    /// in this case, that is the content view
104    pub fn move_with_view(self, state: &AppState) -> Self {
105        let content_view = self.content_view.move_with_state(state);
106        Self {
107            content_view,
108            ..self
109        }
110    }
111
112    /// Move the app with the given state, but only update components that need to be updated.
113    ///
114    /// in this case, that is the active component
115    pub fn move_with_component(self, state: &AppState) -> Self {
116        Self {
117            active_component: state.active_component,
118            ..self
119        }
120    }
121
122    /// Move the app with the given state, but only update components that need to be updated.
123    ///
124    /// in this case, that is the popup
125    pub fn move_with_popup(self, popup: Option<Box<dyn Popup>>) -> Self {
126        Self { popup, ..self }
127    }
128}
129
130impl Component for App {
131    fn new(state: &AppState, action_tx: UnboundedSender<Action>) -> Self
132    where
133        Self: Sized,
134    {
135        Self {
136            action_tx: action_tx.clone(),
137            active_component: state.active_component,
138            //
139            sidebar: Sidebar::new(state, action_tx.clone()),
140            queuebar: QueueBar::new(state, action_tx.clone()),
141            control_panel: ControlPanel::new(state, action_tx.clone()),
142            content_view: ContentView::new(state, action_tx),
143            //
144            popup: None,
145        }
146        .move_with_state(state)
147    }
148
149    fn move_with_state(self, state: &AppState) -> Self
150    where
151        Self: Sized,
152    {
153        Self {
154            sidebar: self.sidebar.move_with_state(state),
155            queuebar: self.queuebar.move_with_state(state),
156            control_panel: self.control_panel.move_with_state(state),
157            content_view: self.content_view.move_with_state(state),
158            popup: self.popup.map(|popup| {
159                let mut popup = popup;
160                popup.update_with_state(state);
161                popup
162            }),
163            ..self
164        }
165    }
166
167    // defer to the active component
168    fn name(&self) -> &str {
169        self.get_active_view_component().name()
170    }
171
172    fn handle_key_event(&mut self, key: KeyEvent) {
173        if key.kind != KeyEventKind::Press {
174            return;
175        }
176
177        // if there is a popup, defer all key handling to it.
178        if let Some(popup) = self.popup.as_mut() {
179            popup.handle_key_event(key, self.action_tx.clone());
180            return;
181        }
182
183        // if it's a exit, or navigation command, handle it here.
184        // otherwise, defer to the active component
185        match key.code {
186            // exit the application
187            KeyCode::Esc => {
188                self.action_tx
189                    .send(Action::General(GeneralAction::Exit))
190                    .unwrap();
191            }
192            // cycle through the components
193            KeyCode::Tab => self
194                .action_tx
195                .send(Action::ActiveComponent(ComponentAction::Next))
196                .unwrap(),
197            KeyCode::BackTab => self
198                .action_tx
199                .send(Action::ActiveComponent(ComponentAction::Previous))
200                .unwrap(),
201            // Refresh the active component
202            KeyCode::F(5) => self
203                .action_tx
204                .send(Action::Library(LibraryAction::Update))
205                .unwrap(),
206            // defer to the active component
207            _ => self.get_active_view_component_mut().handle_key_event(key),
208        }
209    }
210
211    fn handle_mouse_event(&mut self, mouse: crossterm::event::MouseEvent, area: Rect) {
212        // if there is a popup, defer all mouse handling to it.
213        if let Some(popup) = self.popup.as_mut() {
214            popup.handle_mouse_event(mouse, popup.area(area), self.action_tx.clone());
215            return;
216        }
217
218        // adjust area to exclude the border
219        let area = area.inner(Margin::new(1, 1));
220
221        // defer to the component that the mouse is in
222        let mouse_position = Position::new(mouse.column, mouse.row);
223        let Areas {
224            control_panel,
225            sidebar,
226            content_view,
227            queuebar,
228        } = split_area(area);
229
230        if control_panel.contains(mouse_position) {
231            self.control_panel.handle_mouse_event(mouse, control_panel);
232        } else if sidebar.contains(mouse_position) {
233            self.sidebar.handle_mouse_event(mouse, sidebar);
234        } else if content_view.contains(mouse_position) {
235            self.content_view.handle_mouse_event(mouse, content_view);
236        } else if queuebar.contains(mouse_position) {
237            self.queuebar.handle_mouse_event(mouse, queuebar);
238        }
239    }
240}
241
242#[derive(Debug)]
243struct Areas {
244    pub control_panel: Rect,
245    pub sidebar: Rect,
246    pub content_view: Rect,
247    pub queuebar: Rect,
248}
249
250fn split_area(area: Rect) -> Areas {
251    let [main_views, control_panel] = *Layout::default()
252        .direction(Direction::Vertical)
253        .constraints([Constraint::Min(10), Constraint::Length(4)].as_ref())
254        .split(area)
255    else {
256        panic!("Failed to split frame into areas")
257    };
258
259    let [sidebar, content_view, queuebar] = *Layout::default()
260        .direction(Direction::Horizontal)
261        .constraints(
262            [
263                Constraint::Length(19),
264                Constraint::Fill(4),
265                Constraint::Min(25),
266            ]
267            .as_ref(),
268        )
269        .split(main_views)
270    else {
271        panic!("Failed to split main views area")
272    };
273
274    Areas {
275        control_panel,
276        sidebar,
277        content_view,
278        queuebar,
279    }
280}
281
282impl ComponentRender<Rect> for App {
283    fn render_border(&self, frame: &mut Frame<'_>, area: Rect) -> Rect {
284        let block = Block::bordered()
285            .title_top(Span::styled(
286                "MECOMP",
287                Style::default().bold().fg((*APP_BORDER_TEXT).into()),
288            ))
289            .title_bottom(Span::styled(
290                "Tab/Shift+Tab to switch focus | Esc to quit | F5 to refresh",
291                Style::default().fg((*APP_BORDER_TEXT).into()),
292            ))
293            .border_style(Style::default().fg((*APP_BORDER).into()))
294            .style(Style::default().fg((*TEXT_NORMAL).into()));
295        let app_area = block.inner(area);
296        debug_assert_eq!(area.inner(Margin::new(1, 1)), app_area);
297
298        frame.render_widget(block, area);
299        app_area
300    }
301
302    fn render_content(&self, frame: &mut Frame<'_>, area: Rect) {
303        let Areas {
304            control_panel,
305            sidebar,
306            content_view,
307            queuebar,
308        } = split_area(area);
309
310        // figure out the active component, and give it a different colored border
311        let (control_panel_focused, sidebar_focused, content_view_focused, queuebar_focused) =
312            match self.active_component {
313                ActiveComponent::ControlPanel => (true, false, false, false),
314                ActiveComponent::Sidebar => (false, true, false, false),
315                ActiveComponent::ContentView => (false, false, true, false),
316                ActiveComponent::QueueBar => (false, false, false, true),
317            };
318
319        // render the control panel
320        self.control_panel.render(
321            frame,
322            RenderProps {
323                area: control_panel,
324                is_focused: control_panel_focused,
325            },
326        );
327
328        // render the sidebar
329        self.sidebar.render(
330            frame,
331            RenderProps {
332                area: sidebar,
333                is_focused: sidebar_focused,
334            },
335        );
336
337        // render the content view
338        self.content_view.render(
339            frame,
340            RenderProps {
341                area: content_view,
342                is_focused: content_view_focused,
343            },
344        );
345
346        // render the queuebar
347        self.queuebar.render(
348            frame,
349            RenderProps {
350                area: queuebar,
351                is_focused: queuebar_focused,
352            },
353        );
354
355        // render the popup if there is one
356        if let Some(popup) = &self.popup {
357            popup.render_popup(frame);
358        }
359    }
360}
361
362#[cfg(test)]
363mod tests {
364    use std::time::Duration;
365
366    use super::*;
367    use crate::{
368        state::action::PopupAction,
369        test_utils::setup_test_terminal,
370        ui::{
371            components::{self, content_view::ActiveView},
372            widgets::popups::notification::Notification,
373        },
374    };
375    use crossterm::event::KeyModifiers;
376    use mecomp_core::state::{Percent, RepeatMode, StateAudio, StateRuntime, Status};
377    use mecomp_prost::{LibraryBrief, SearchResult};
378    use mecomp_storage::db::schemas::song::{Song, SongBrief};
379    use pretty_assertions::assert_eq;
380    use rstest::{fixture, rstest};
381    use tokio::sync::mpsc::unbounded_channel;
382
383    #[fixture]
384    fn song() -> SongBrief {
385        SongBrief {
386            id: Song::generate_id(),
387            title: "Test Song".into(),
388            artist: "Test Artist".to_string().into(),
389            album_artist: "Test Album Artist".to_string().into(),
390            album: "Test Album".into(),
391            genre: "Test Genre".to_string().into(),
392            runtime: Duration::from_secs(180),
393            track: Some(0),
394            disc: Some(0),
395            release_year: Some(2021),
396            path: "test.mp3".into(),
397        }
398    }
399
400    #[rstest]
401    #[case::tab(KeyCode::Tab, Action::ActiveComponent(ComponentAction::Next))]
402    #[case::back_tab(KeyCode::BackTab, Action::ActiveComponent(ComponentAction::Previous))]
403    #[case::esc(KeyCode::Esc, Action::General(GeneralAction::Exit))]
404    fn test_actions(#[case] key_code: KeyCode, #[case] expected: Action) {
405        let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
406        let mut app = App::new(&AppState::default(), tx);
407
408        app.handle_key_event(KeyEvent::from(key_code));
409
410        let action = rx.blocking_recv().unwrap();
411
412        assert_eq!(action, expected);
413    }
414
415    #[rstest]
416    #[case::sidebar(ActiveComponent::Sidebar)]
417    #[case::content_view(ActiveComponent::ContentView)]
418    #[case::queuebar(ActiveComponent::QueueBar)]
419    #[case::control_panel(ActiveComponent::ControlPanel)]
420    fn smoke_render(#[case] active_component: ActiveComponent) {
421        let (tx, _) = tokio::sync::mpsc::unbounded_channel();
422        let app = App::new(
423            &AppState {
424                active_component,
425                ..Default::default()
426            },
427            tx,
428        );
429
430        let (mut terminal, area) = setup_test_terminal(100, 100);
431        let completed_frame = terminal.draw(|frame| app.render(frame, area));
432
433        assert!(completed_frame.is_ok());
434    }
435
436    #[rstest]
437    #[case::sidebar(ActiveComponent::Sidebar)]
438    #[case::content_view(ActiveComponent::ContentView)]
439    #[case::queuebar(ActiveComponent::QueueBar)]
440    #[case::control_panel(ActiveComponent::ControlPanel)]
441    fn test_render_with_popup(#[case] active_component: ActiveComponent) {
442        let (tx, _) = tokio::sync::mpsc::unbounded_channel();
443        let app = App::new(
444            &AppState {
445                active_component,
446                ..Default::default()
447            },
448            tx,
449        );
450
451        let (mut terminal, area) = setup_test_terminal(100, 100);
452        let pre_popup = terminal.draw(|frame| app.render(frame, area)).unwrap();
453
454        let app = app.move_with_popup(Some(Box::new(Notification::new(
455            "Hello, World!".into(),
456            unbounded_channel().0,
457        ))));
458
459        let (mut terminal, area) = setup_test_terminal(100, 100);
460        let post_popup = terminal.draw(|frame| app.render(frame, area)).unwrap();
461
462        assert!(!pre_popup.buffer.diff(post_popup.buffer).is_empty());
463    }
464
465    #[rstest]
466    #[case::sidebar(ActiveComponent::Sidebar)]
467    #[case::content_view(ActiveComponent::ContentView)]
468    #[case::queuebar(ActiveComponent::QueueBar)]
469    #[case::control_panel(ActiveComponent::ControlPanel)]
470    #[tokio::test]
471    async fn test_popup_takes_over_key_events(#[case] active_component: ActiveComponent) {
472        let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
473        let mut app = App::new(
474            &AppState {
475                active_component,
476                ..Default::default()
477            },
478            tx,
479        );
480
481        let (mut terminal, area) = setup_test_terminal(100, 100);
482        let pre_popup = terminal.draw(|frame| app.render(frame, area)).unwrap();
483
484        let popup = Box::new(Notification::new(
485            "Hello, World!".into(),
486            unbounded_channel().0,
487        ));
488        app = app.move_with_popup(Some(popup));
489
490        let (mut terminal, area) = setup_test_terminal(100, 100);
491        let post_popup = terminal.draw(|frame| app.render(frame, area)).unwrap();
492
493        // assert that the popup is rendered
494        assert!(!pre_popup.buffer.diff(post_popup.buffer).is_empty());
495
496        // now, send a Esc key event to the app
497        app.handle_key_event(KeyEvent::from(KeyCode::Esc));
498
499        // assert that we received a close popup action
500        let action = rx.recv().await.unwrap();
501        assert_eq!(action, Action::Popup(PopupAction::Close));
502
503        // close the popup (the action handler isn't running so we have to do it manually)
504        app = app.move_with_popup(None);
505
506        let (mut terminal, area) = setup_test_terminal(100, 100);
507        let post_close = terminal.draw(|frame| app.render(frame, area)).unwrap();
508
509        // assert that the popup is no longer rendered
510        assert!(!post_popup.buffer.diff(post_close.buffer).is_empty());
511        assert!(pre_popup.buffer.diff(post_close.buffer).is_empty());
512    }
513
514    #[rstest]
515    fn test_move_with_search(song: SongBrief) {
516        let (tx, _) = tokio::sync::mpsc::unbounded_channel();
517        let state = AppState::default();
518        let mut app = App::new(&state, tx);
519
520        let state = AppState {
521            search: SearchResult {
522                songs: vec![song.into()],
523                ..Default::default()
524            },
525            ..state
526        };
527        app = app.move_with_search(&state);
528
529        assert_eq!(
530            app.content_view.search_view.props.search_results,
531            state.search,
532        );
533    }
534
535    #[rstest]
536    fn test_move_with_audio(song: SongBrief) {
537        let (tx, _) = tokio::sync::mpsc::unbounded_channel();
538        let state = AppState::default();
539        let mut app = App::new(&state, tx);
540
541        let state = AppState {
542            audio: StateAudio {
543                queue: vec![song.clone()].into_boxed_slice(),
544                queue_position: Some(0),
545                current_song: Some(song.clone()),
546                repeat_mode: RepeatMode::One,
547                runtime: Some(StateRuntime {
548                    seek_position: Duration::from_secs(0),
549                    seek_percent: Percent::new(0.0),
550                    duration: song.runtime,
551                }),
552                status: Status::Stopped,
553                muted: false,
554                volume: 1.0,
555            },
556            ..state
557        };
558        app = app.move_with_audio(&state);
559
560        let components::queuebar::Props {
561            queue,
562            current_position,
563            repeat_mode,
564        } = app.queuebar.props;
565        assert_eq!(queue, state.audio.queue);
566        assert_eq!(current_position, state.audio.queue_position);
567        assert_eq!(repeat_mode, state.audio.repeat_mode);
568
569        let components::control_panel::Props {
570            is_playing,
571            muted,
572            volume,
573            song_runtime,
574            song_title,
575            song_artist,
576        } = app.control_panel.props;
577
578        assert_eq!(is_playing, !state.audio.paused());
579        assert_eq!(muted, state.audio.muted);
580        assert!(
581            f32::EPSILON > (volume - state.audio.volume).abs(),
582            "{} != {}",
583            volume,
584            state.audio.volume
585        );
586        assert_eq!(song_runtime, state.audio.runtime);
587        assert_eq!(
588            song_title,
589            state
590                .audio
591                .current_song
592                .as_ref()
593                .map(|song| song.title.to_string())
594        );
595        assert_eq!(
596            song_artist,
597            state
598                .audio
599                .current_song
600                .as_ref()
601                .map(|song| { song.artist.as_slice().join(", ") })
602        );
603    }
604
605    #[rstest]
606    fn test_move_with_library(song: SongBrief) {
607        let (tx, _) = tokio::sync::mpsc::unbounded_channel();
608        let state = AppState {
609            active_component: ActiveComponent::ContentView,
610            active_view: ActiveView::Songs,
611            ..Default::default()
612        };
613        let mut app = App::new(&state, tx);
614
615        let state = AppState {
616            library: LibraryBrief {
617                songs: vec![song.into()],
618                ..Default::default()
619            },
620            ..state
621        };
622        app = app.move_with_library(&state);
623
624        assert_eq!(app.content_view.songs_view.props.songs, state.library.songs);
625    }
626
627    #[rstest]
628    fn test_move_with_view(song: SongBrief) {
629        let (tx, _) = tokio::sync::mpsc::unbounded_channel();
630        let state = AppState {
631            active_component: ActiveComponent::ContentView,
632            active_view: ActiveView::Songs,
633            ..Default::default()
634        };
635        let mut app = App::new(&state, tx);
636
637        let state = AppState {
638            active_view: ActiveView::Song(song.id.key().to_string().into()),
639            ..state
640        };
641        app = app.move_with_view(&state);
642
643        assert_eq!(app.content_view.props.active_view, state.active_view);
644    }
645
646    #[test]
647    fn test_move_with_component() {
648        let (tx, _) = tokio::sync::mpsc::unbounded_channel();
649        let app = App::new(&AppState::default(), tx);
650
651        assert_eq!(app.active_component, ActiveComponent::Sidebar);
652
653        let state = AppState {
654            active_component: ActiveComponent::QueueBar,
655            ..Default::default()
656        };
657        let app = app.move_with_component(&state);
658
659        assert_eq!(app.active_component, ActiveComponent::QueueBar);
660    }
661
662    #[rstest]
663    fn test_move_with_popup() {
664        let (tx, _) = tokio::sync::mpsc::unbounded_channel();
665        let app = App::new(&AppState::default(), tx);
666
667        assert!(app.popup.is_none());
668
669        let popup = Box::new(Notification::new(
670            "Hello, World!".into(),
671            unbounded_channel().0,
672        ));
673        let app = app.move_with_popup(Some(popup));
674
675        assert!(app.popup.is_some());
676    }
677
678    #[rstest]
679    #[case::sidebar(ActiveComponent::Sidebar)]
680    #[case::content_view(ActiveComponent::ContentView)]
681    #[case::queuebar(ActiveComponent::QueueBar)]
682    #[case::control_panel(ActiveComponent::ControlPanel)]
683    fn test_get_active_view_component(#[case] active_component: ActiveComponent) {
684        let (tx, _) = tokio::sync::mpsc::unbounded_channel();
685        let state = AppState {
686            active_component,
687            ..Default::default()
688        };
689        let app = App::new(&state, tx.clone());
690
691        let component = app.get_active_view_component();
692
693        match active_component {
694            ActiveComponent::Sidebar => assert_eq!(component.name(), "Sidebar"),
695            ActiveComponent::ContentView => assert_eq!(component.name(), "None"), // default content view is the None view, and it defers it's `name()` to the active view
696            ActiveComponent::QueueBar => assert_eq!(component.name(), "Queue"),
697            ActiveComponent::ControlPanel => assert_eq!(component.name(), "ControlPanel"),
698        }
699
700        // assert that the two "get_active_view_component" methods return the same component
701        assert_eq!(
702            component.name(),
703            App::new(&state, tx,).get_active_view_component_mut().name()
704        );
705    }
706
707    #[test]
708    fn test_click_to_focus() {
709        let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
710        let mut app = App::new(&AppState::default(), tx);
711
712        let (mut terminal, area) = setup_test_terminal(100, 100);
713        let _frame = terminal.draw(|frame| app.render(frame, area)).unwrap();
714
715        let mouse = crossterm::event::MouseEvent {
716            kind: crossterm::event::MouseEventKind::Down(crossterm::event::MouseButton::Left),
717            column: 2,
718            row: 2,
719            modifiers: KeyModifiers::empty(),
720        };
721        app.handle_mouse_event(mouse, area);
722
723        let action = rx.blocking_recv().unwrap();
724        assert_eq!(
725            action,
726            Action::ActiveComponent(ComponentAction::Set(ActiveComponent::Sidebar))
727        );
728
729        let mouse = crossterm::event::MouseEvent {
730            kind: crossterm::event::MouseEventKind::Down(crossterm::event::MouseButton::Left),
731            column: 50,
732            row: 10,
733            modifiers: KeyModifiers::empty(),
734        };
735        app.handle_mouse_event(mouse, area);
736
737        let action = rx.blocking_recv().unwrap();
738        assert_eq!(
739            action,
740            Action::ActiveComponent(ComponentAction::Set(ActiveComponent::ContentView))
741        );
742
743        let mouse = crossterm::event::MouseEvent {
744            kind: crossterm::event::MouseEventKind::Down(crossterm::event::MouseButton::Left),
745            column: 90,
746            row: 10,
747            modifiers: KeyModifiers::empty(),
748        };
749        app.handle_mouse_event(mouse, area);
750
751        let action = rx.blocking_recv().unwrap();
752        assert_eq!(
753            action,
754            Action::ActiveComponent(ComponentAction::Set(ActiveComponent::QueueBar))
755        );
756
757        let mouse = crossterm::event::MouseEvent {
758            kind: crossterm::event::MouseEventKind::Down(crossterm::event::MouseButton::Left),
759            column: 60,
760            row: 98,
761            modifiers: KeyModifiers::empty(),
762        };
763        app.handle_mouse_event(mouse, area);
764
765        let action = rx.blocking_recv().unwrap();
766        assert_eq!(
767            action,
768            Action::ActiveComponent(ComponentAction::Set(ActiveComponent::ControlPanel))
769        );
770    }
771}