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::{
377        rpc::SearchResult,
378        state::{Percent, RepeatMode, StateAudio, StateRuntime, Status, library::LibraryBrief},
379    };
380    use mecomp_storage::db::schemas::song::{Song, SongBrief};
381    use pretty_assertions::assert_eq;
382    use rstest::{fixture, rstest};
383    use tokio::sync::mpsc::unbounded_channel;
384
385    #[fixture]
386    fn song() -> SongBrief {
387        SongBrief {
388            id: Song::generate_id(),
389            title: "Test Song".into(),
390            artist: "Test Artist".to_string().into(),
391            album_artist: "Test Album Artist".to_string().into(),
392            album: "Test Album".into(),
393            genre: "Test Genre".to_string().into(),
394            runtime: Duration::from_secs(180),
395            track: Some(0),
396            disc: Some(0),
397            release_year: Some(2021),
398            extension: "mp3".into(),
399            path: "test.mp3".into(),
400        }
401    }
402
403    #[rstest]
404    #[case::tab(KeyCode::Tab, Action::ActiveComponent(ComponentAction::Next))]
405    #[case::back_tab(KeyCode::BackTab, Action::ActiveComponent(ComponentAction::Previous))]
406    #[case::esc(KeyCode::Esc, Action::General(GeneralAction::Exit))]
407    fn test_actions(#[case] key_code: KeyCode, #[case] expected: Action) {
408        let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
409        let mut app = App::new(&AppState::default(), tx);
410
411        app.handle_key_event(KeyEvent::from(key_code));
412
413        let action = rx.blocking_recv().unwrap();
414
415        assert_eq!(action, expected);
416    }
417
418    #[rstest]
419    #[case::sidebar(ActiveComponent::Sidebar)]
420    #[case::content_view(ActiveComponent::ContentView)]
421    #[case::queuebar(ActiveComponent::QueueBar)]
422    #[case::control_panel(ActiveComponent::ControlPanel)]
423    fn smoke_render(#[case] active_component: ActiveComponent) {
424        let (tx, _) = tokio::sync::mpsc::unbounded_channel();
425        let app = App::new(
426            &AppState {
427                active_component,
428                ..Default::default()
429            },
430            tx,
431        );
432
433        let (mut terminal, area) = setup_test_terminal(100, 100);
434        let completed_frame = terminal.draw(|frame| app.render(frame, area));
435
436        assert!(completed_frame.is_ok());
437    }
438
439    #[rstest]
440    #[case::sidebar(ActiveComponent::Sidebar)]
441    #[case::content_view(ActiveComponent::ContentView)]
442    #[case::queuebar(ActiveComponent::QueueBar)]
443    #[case::control_panel(ActiveComponent::ControlPanel)]
444    fn test_render_with_popup(#[case] active_component: ActiveComponent) {
445        let (tx, _) = tokio::sync::mpsc::unbounded_channel();
446        let app = App::new(
447            &AppState {
448                active_component,
449                ..Default::default()
450            },
451            tx,
452        );
453
454        let (mut terminal, area) = setup_test_terminal(100, 100);
455        let pre_popup = terminal.draw(|frame| app.render(frame, area)).unwrap();
456
457        let app = app.move_with_popup(Some(Box::new(Notification::new(
458            "Hello, World!".into(),
459            unbounded_channel().0,
460        ))));
461
462        let (mut terminal, area) = setup_test_terminal(100, 100);
463        let post_popup = terminal.draw(|frame| app.render(frame, area)).unwrap();
464
465        assert!(!pre_popup.buffer.diff(post_popup.buffer).is_empty());
466    }
467
468    #[rstest]
469    #[case::sidebar(ActiveComponent::Sidebar)]
470    #[case::content_view(ActiveComponent::ContentView)]
471    #[case::queuebar(ActiveComponent::QueueBar)]
472    #[case::control_panel(ActiveComponent::ControlPanel)]
473    #[tokio::test]
474    async fn test_popup_takes_over_key_events(#[case] active_component: ActiveComponent) {
475        let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
476        let mut app = App::new(
477            &AppState {
478                active_component,
479                ..Default::default()
480            },
481            tx,
482        );
483
484        let (mut terminal, area) = setup_test_terminal(100, 100);
485        let pre_popup = terminal.draw(|frame| app.render(frame, area)).unwrap();
486
487        let popup = Box::new(Notification::new(
488            "Hello, World!".into(),
489            unbounded_channel().0,
490        ));
491        app = app.move_with_popup(Some(popup));
492
493        let (mut terminal, area) = setup_test_terminal(100, 100);
494        let post_popup = terminal.draw(|frame| app.render(frame, area)).unwrap();
495
496        // assert that the popup is rendered
497        assert!(!pre_popup.buffer.diff(post_popup.buffer).is_empty());
498
499        // now, send a Esc key event to the app
500        app.handle_key_event(KeyEvent::from(KeyCode::Esc));
501
502        // assert that we received a close popup action
503        let action = rx.recv().await.unwrap();
504        assert_eq!(action, Action::Popup(PopupAction::Close));
505
506        // close the popup (the action handler isn't running so we have to do it manually)
507        app = app.move_with_popup(None);
508
509        let (mut terminal, area) = setup_test_terminal(100, 100);
510        let post_close = terminal.draw(|frame| app.render(frame, area)).unwrap();
511
512        // assert that the popup is no longer rendered
513        assert!(!post_popup.buffer.diff(post_close.buffer).is_empty());
514        assert!(pre_popup.buffer.diff(post_close.buffer).is_empty());
515    }
516
517    #[rstest]
518    fn test_move_with_search(song: SongBrief) {
519        let (tx, _) = tokio::sync::mpsc::unbounded_channel();
520        let state = AppState::default();
521        let mut app = App::new(&state, tx);
522
523        let state = AppState {
524            search: SearchResult {
525                songs: vec![song].into_boxed_slice(),
526                ..Default::default()
527            },
528            ..state
529        };
530        app = app.move_with_search(&state);
531
532        assert_eq!(
533            app.content_view.search_view.props.search_results,
534            state.search,
535        );
536    }
537
538    #[rstest]
539    fn test_move_with_audio(song: SongBrief) {
540        let (tx, _) = tokio::sync::mpsc::unbounded_channel();
541        let state = AppState::default();
542        let mut app = App::new(&state, tx);
543
544        let state = AppState {
545            audio: StateAudio {
546                queue: vec![song.clone()].into_boxed_slice(),
547                queue_position: Some(0),
548                current_song: Some(song.clone()),
549                repeat_mode: RepeatMode::One,
550                runtime: Some(StateRuntime {
551                    seek_position: Duration::from_secs(0),
552                    seek_percent: Percent::new(0.0),
553                    duration: song.runtime,
554                }),
555                status: Status::Stopped,
556                muted: false,
557                volume: 1.0,
558            },
559            ..state
560        };
561        app = app.move_with_audio(&state);
562
563        let components::queuebar::Props {
564            queue,
565            current_position,
566            repeat_mode,
567        } = app.queuebar.props;
568        assert_eq!(queue, state.audio.queue);
569        assert_eq!(current_position, state.audio.queue_position);
570        assert_eq!(repeat_mode, state.audio.repeat_mode);
571
572        let components::control_panel::Props {
573            is_playing,
574            muted,
575            volume,
576            song_runtime,
577            song_title,
578            song_artist,
579        } = app.control_panel.props;
580
581        assert_eq!(is_playing, !state.audio.paused());
582        assert_eq!(muted, state.audio.muted);
583        assert!(
584            f32::EPSILON > (volume - state.audio.volume).abs(),
585            "{} != {}",
586            volume,
587            state.audio.volume
588        );
589        assert_eq!(song_runtime, state.audio.runtime);
590        assert_eq!(
591            song_title,
592            state
593                .audio
594                .current_song
595                .as_ref()
596                .map(|song| song.title.to_string())
597        );
598        assert_eq!(
599            song_artist,
600            state.audio.current_song.as_ref().map(|song| {
601                song.artist
602                    .iter()
603                    .map(ToString::to_string)
604                    .collect::<Vec<String>>()
605                    .join(", ")
606            })
607        );
608    }
609
610    #[rstest]
611    fn test_move_with_library(song: SongBrief) {
612        let (tx, _) = tokio::sync::mpsc::unbounded_channel();
613        let state = AppState {
614            active_component: ActiveComponent::ContentView,
615            active_view: ActiveView::Songs,
616            ..Default::default()
617        };
618        let mut app = App::new(&state, tx);
619
620        let state = AppState {
621            library: LibraryBrief {
622                songs: vec![song].into_boxed_slice(),
623                ..Default::default()
624            },
625            ..state
626        };
627        app = app.move_with_library(&state);
628
629        assert_eq!(app.content_view.songs_view.props.songs, state.library.songs);
630    }
631
632    #[rstest]
633    fn test_move_with_view(song: SongBrief) {
634        let (tx, _) = tokio::sync::mpsc::unbounded_channel();
635        let state = AppState {
636            active_component: ActiveComponent::ContentView,
637            active_view: ActiveView::Songs,
638            ..Default::default()
639        };
640        let mut app = App::new(&state, tx);
641
642        let state = AppState {
643            active_view: ActiveView::Song(song.id.key().to_owned().into()),
644            ..state
645        };
646        app = app.move_with_view(&state);
647
648        assert_eq!(app.content_view.props.active_view, state.active_view);
649    }
650
651    #[test]
652    fn test_move_with_component() {
653        let (tx, _) = tokio::sync::mpsc::unbounded_channel();
654        let app = App::new(&AppState::default(), tx);
655
656        assert_eq!(app.active_component, ActiveComponent::Sidebar);
657
658        let state = AppState {
659            active_component: ActiveComponent::QueueBar,
660            ..Default::default()
661        };
662        let app = app.move_with_component(&state);
663
664        assert_eq!(app.active_component, ActiveComponent::QueueBar);
665    }
666
667    #[rstest]
668    fn test_move_with_popup() {
669        let (tx, _) = tokio::sync::mpsc::unbounded_channel();
670        let app = App::new(&AppState::default(), tx);
671
672        assert!(app.popup.is_none());
673
674        let popup = Box::new(Notification::new(
675            "Hello, World!".into(),
676            unbounded_channel().0,
677        ));
678        let app = app.move_with_popup(Some(popup));
679
680        assert!(app.popup.is_some());
681    }
682
683    #[rstest]
684    #[case::sidebar(ActiveComponent::Sidebar)]
685    #[case::content_view(ActiveComponent::ContentView)]
686    #[case::queuebar(ActiveComponent::QueueBar)]
687    #[case::control_panel(ActiveComponent::ControlPanel)]
688    fn test_get_active_view_component(#[case] active_component: ActiveComponent) {
689        let (tx, _) = tokio::sync::mpsc::unbounded_channel();
690        let state = AppState {
691            active_component,
692            ..Default::default()
693        };
694        let app = App::new(&state, tx.clone());
695
696        let component = app.get_active_view_component();
697
698        match active_component {
699            ActiveComponent::Sidebar => assert_eq!(component.name(), "Sidebar"),
700            ActiveComponent::ContentView => assert_eq!(component.name(), "None"), // default content view is the None view, and it defers it's `name()` to the active view
701            ActiveComponent::QueueBar => assert_eq!(component.name(), "Queue"),
702            ActiveComponent::ControlPanel => assert_eq!(component.name(), "ControlPanel"),
703        }
704
705        // assert that the two "get_active_view_component" methods return the same component
706        assert_eq!(
707            component.name(),
708            App::new(&state, tx,).get_active_view_component_mut().name()
709        );
710    }
711
712    #[test]
713    fn test_click_to_focus() {
714        let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
715        let mut app = App::new(&AppState::default(), tx);
716
717        let (mut terminal, area) = setup_test_terminal(100, 100);
718        let _frame = terminal.draw(|frame| app.render(frame, area)).unwrap();
719
720        let mouse = crossterm::event::MouseEvent {
721            kind: crossterm::event::MouseEventKind::Down(crossterm::event::MouseButton::Left),
722            column: 2,
723            row: 2,
724            modifiers: KeyModifiers::empty(),
725        };
726        app.handle_mouse_event(mouse, area);
727
728        let action = rx.blocking_recv().unwrap();
729        assert_eq!(
730            action,
731            Action::ActiveComponent(ComponentAction::Set(ActiveComponent::Sidebar))
732        );
733
734        let mouse = crossterm::event::MouseEvent {
735            kind: crossterm::event::MouseEventKind::Down(crossterm::event::MouseButton::Left),
736            column: 50,
737            row: 10,
738            modifiers: KeyModifiers::empty(),
739        };
740        app.handle_mouse_event(mouse, area);
741
742        let action = rx.blocking_recv().unwrap();
743        assert_eq!(
744            action,
745            Action::ActiveComponent(ComponentAction::Set(ActiveComponent::ContentView))
746        );
747
748        let mouse = crossterm::event::MouseEvent {
749            kind: crossterm::event::MouseEventKind::Down(crossterm::event::MouseButton::Left),
750            column: 90,
751            row: 10,
752            modifiers: KeyModifiers::empty(),
753        };
754        app.handle_mouse_event(mouse, area);
755
756        let action = rx.blocking_recv().unwrap();
757        assert_eq!(
758            action,
759            Action::ActiveComponent(ComponentAction::Set(ActiveComponent::QueueBar))
760        );
761
762        let mouse = crossterm::event::MouseEvent {
763            kind: crossterm::event::MouseEventKind::Down(crossterm::event::MouseButton::Left),
764            column: 60,
765            row: 98,
766            modifiers: KeyModifiers::empty(),
767        };
768        app.handle_mouse_event(mouse, area);
769
770        let action = rx.blocking_recv().unwrap();
771        assert_eq!(
772            action,
773            Action::ActiveComponent(ComponentAction::Set(ActiveComponent::ControlPanel))
774        );
775    }
776}