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},
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            // defer to the active component
202            _ => self.get_active_view_component_mut().handle_key_event(key),
203        }
204    }
205
206    fn handle_mouse_event(&mut self, mouse: crossterm::event::MouseEvent, area: Rect) {
207        // if there is a popup, defer all mouse handling to it.
208        if let Some(popup) = self.popup.as_mut() {
209            popup.handle_mouse_event(mouse, popup.area(area), self.action_tx.clone());
210            return;
211        }
212
213        // adjust area to exclude the border
214        let area = area.inner(Margin::new(1, 1));
215
216        // defer to the component that the mouse is in
217        let mouse_position = Position::new(mouse.column, mouse.row);
218        let Areas {
219            control_panel,
220            sidebar,
221            content_view,
222            queuebar,
223        } = split_area(area);
224
225        if control_panel.contains(mouse_position) {
226            self.control_panel.handle_mouse_event(mouse, control_panel);
227        } else if sidebar.contains(mouse_position) {
228            self.sidebar.handle_mouse_event(mouse, sidebar);
229        } else if content_view.contains(mouse_position) {
230            self.content_view.handle_mouse_event(mouse, content_view);
231        } else if queuebar.contains(mouse_position) {
232            self.queuebar.handle_mouse_event(mouse, queuebar);
233        }
234    }
235}
236
237#[derive(Debug)]
238struct Areas {
239    pub control_panel: Rect,
240    pub sidebar: Rect,
241    pub content_view: Rect,
242    pub queuebar: Rect,
243}
244
245fn split_area(area: Rect) -> Areas {
246    let [main_views, control_panel] = *Layout::default()
247        .direction(Direction::Vertical)
248        .constraints([Constraint::Min(10), Constraint::Length(4)].as_ref())
249        .split(area)
250    else {
251        panic!("Failed to split frame into areas")
252    };
253
254    let [sidebar, content_view, queuebar] = *Layout::default()
255        .direction(Direction::Horizontal)
256        .constraints(
257            [
258                Constraint::Length(19),
259                Constraint::Fill(4),
260                Constraint::Min(25),
261            ]
262            .as_ref(),
263        )
264        .split(main_views)
265    else {
266        panic!("Failed to split main views area")
267    };
268
269    Areas {
270        control_panel,
271        sidebar,
272        content_view,
273        queuebar,
274    }
275}
276
277impl ComponentRender<Rect> for App {
278    fn render_border(&self, frame: &mut Frame, area: Rect) -> Rect {
279        let block = Block::bordered()
280            .title_top(Span::styled(
281                "MECOMP",
282                Style::default().bold().fg(APP_BORDER_TEXT.into()),
283            ))
284            .title_bottom(Span::styled(
285                "Tab/Shift+Tab to switch focus | Esc to quit",
286                Style::default().fg(APP_BORDER_TEXT.into()),
287            ))
288            .border_style(Style::default().fg(APP_BORDER.into()))
289            .style(Style::default().fg(TEXT_NORMAL.into()));
290        let app_area = block.inner(area);
291        debug_assert_eq!(area.inner(Margin::new(1, 1)), app_area);
292
293        frame.render_widget(block, area);
294        app_area
295    }
296
297    fn render_content(&self, frame: &mut Frame, area: Rect) {
298        let Areas {
299            control_panel,
300            sidebar,
301            content_view,
302            queuebar,
303        } = split_area(area);
304
305        // figure out the active component, and give it a different colored border
306        let (control_panel_focused, sidebar_focused, content_view_focused, queuebar_focused) =
307            match self.active_component {
308                ActiveComponent::ControlPanel => (true, false, false, false),
309                ActiveComponent::Sidebar => (false, true, false, false),
310                ActiveComponent::ContentView => (false, false, true, false),
311                ActiveComponent::QueueBar => (false, false, false, true),
312            };
313
314        // render the control panel
315        self.control_panel.render(
316            frame,
317            RenderProps {
318                area: control_panel,
319                is_focused: control_panel_focused,
320            },
321        );
322
323        // render the sidebar
324        self.sidebar.render(
325            frame,
326            RenderProps {
327                area: sidebar,
328                is_focused: sidebar_focused,
329            },
330        );
331
332        // render the content view
333        self.content_view.render(
334            frame,
335            RenderProps {
336                area: content_view,
337                is_focused: content_view_focused,
338            },
339        );
340
341        // render the queuebar
342        self.queuebar.render(
343            frame,
344            RenderProps {
345                area: queuebar,
346                is_focused: queuebar_focused,
347            },
348        );
349
350        // render the popup if there is one
351        if let Some(popup) = &self.popup {
352            popup.render_popup(frame);
353        }
354    }
355}
356
357#[cfg(test)]
358mod tests {
359    use std::time::Duration;
360
361    use super::*;
362    use crate::{
363        state::action::PopupAction,
364        test_utils::setup_test_terminal,
365        ui::{
366            components::{self, content_view::ActiveView},
367            widgets::popups::notification::Notification,
368        },
369    };
370    use crossterm::event::KeyModifiers;
371    use mecomp_core::{
372        rpc::SearchResult,
373        state::{Percent, RepeatMode, StateAudio, StateRuntime, Status, library::LibraryFull},
374    };
375    use mecomp_storage::db::schemas::song::Song;
376    use one_or_many::OneOrMany;
377    use pretty_assertions::assert_eq;
378    use rstest::{fixture, rstest};
379    use tokio::sync::mpsc::unbounded_channel;
380
381    #[fixture]
382    fn song() -> Song {
383        Song {
384            id: Song::generate_id(),
385            title: "Test Song".into(),
386            artist: OneOrMany::One("Test Artist".into()),
387            album_artist: OneOrMany::One("Test Album Artist".into()),
388            album: "Test Album".into(),
389            genre: OneOrMany::One("Test Genre".into()),
390            runtime: Duration::from_secs(180),
391            track: Some(0),
392            disc: Some(0),
393            release_year: Some(2021),
394            extension: "mp3".into(),
395            path: "test.mp3".into(),
396        }
397    }
398
399    #[rstest]
400    #[case::tab(KeyCode::Tab, Action::ActiveComponent(ComponentAction::Next))]
401    #[case::back_tab(KeyCode::BackTab, Action::ActiveComponent(ComponentAction::Previous))]
402    #[case::esc(KeyCode::Esc, Action::General(GeneralAction::Exit))]
403    fn test_actions(#[case] key_code: KeyCode, #[case] expected: Action) {
404        let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
405        let mut app = App::new(&AppState::default(), tx);
406
407        app.handle_key_event(KeyEvent::from(key_code));
408
409        let action = rx.blocking_recv().unwrap();
410
411        assert_eq!(action, expected);
412    }
413
414    #[rstest]
415    #[case::sidebar(ActiveComponent::Sidebar)]
416    #[case::content_view(ActiveComponent::ContentView)]
417    #[case::queuebar(ActiveComponent::QueueBar)]
418    #[case::control_panel(ActiveComponent::ControlPanel)]
419    fn smoke_render(#[case] active_component: ActiveComponent) {
420        let (tx, _) = tokio::sync::mpsc::unbounded_channel();
421        let app = App::new(
422            &AppState {
423                active_component,
424                ..Default::default()
425            },
426            tx,
427        );
428
429        let (mut terminal, area) = setup_test_terminal(100, 100);
430        let completed_frame = terminal.draw(|frame| app.render(frame, area));
431
432        assert!(completed_frame.is_ok());
433    }
434
435    #[rstest]
436    #[case::sidebar(ActiveComponent::Sidebar)]
437    #[case::content_view(ActiveComponent::ContentView)]
438    #[case::queuebar(ActiveComponent::QueueBar)]
439    #[case::control_panel(ActiveComponent::ControlPanel)]
440    fn test_render_with_popup(#[case] active_component: ActiveComponent) {
441        let (tx, _) = tokio::sync::mpsc::unbounded_channel();
442        let app = App::new(
443            &AppState {
444                active_component,
445                ..Default::default()
446            },
447            tx,
448        );
449
450        let (mut terminal, area) = setup_test_terminal(100, 100);
451        let pre_popup = terminal.draw(|frame| app.render(frame, area)).unwrap();
452
453        let app = app.move_with_popup(Some(Box::new(Notification::new(
454            "Hello, World!".into(),
455            unbounded_channel().0,
456        ))));
457
458        let (mut terminal, area) = setup_test_terminal(100, 100);
459        let post_popup = terminal.draw(|frame| app.render(frame, area)).unwrap();
460
461        assert!(!pre_popup.buffer.diff(post_popup.buffer).is_empty());
462    }
463
464    #[rstest]
465    #[case::sidebar(ActiveComponent::Sidebar)]
466    #[case::content_view(ActiveComponent::ContentView)]
467    #[case::queuebar(ActiveComponent::QueueBar)]
468    #[case::control_panel(ActiveComponent::ControlPanel)]
469    #[tokio::test]
470    async fn test_popup_takes_over_key_events(#[case] active_component: ActiveComponent) {
471        let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
472        let mut app = App::new(
473            &AppState {
474                active_component,
475                ..Default::default()
476            },
477            tx,
478        );
479
480        let (mut terminal, area) = setup_test_terminal(100, 100);
481        let pre_popup = terminal.draw(|frame| app.render(frame, area)).unwrap();
482
483        let popup = Box::new(Notification::new(
484            "Hello, World!".into(),
485            unbounded_channel().0,
486        ));
487        app = app.move_with_popup(Some(popup));
488
489        let (mut terminal, area) = setup_test_terminal(100, 100);
490        let post_popup = terminal.draw(|frame| app.render(frame, area)).unwrap();
491
492        // assert that the popup is rendered
493        assert!(!pre_popup.buffer.diff(post_popup.buffer).is_empty());
494
495        // now, send a Esc key event to the app
496        app.handle_key_event(KeyEvent::from(KeyCode::Esc));
497
498        // assert that we received a close popup action
499        let action = rx.recv().await.unwrap();
500        assert_eq!(action, Action::Popup(PopupAction::Close));
501
502        // close the popup (the action handler isn't running so we have to do it manually)
503        app = app.move_with_popup(None);
504
505        let (mut terminal, area) = setup_test_terminal(100, 100);
506        let post_close = terminal.draw(|frame| app.render(frame, area)).unwrap();
507
508        // assert that the popup is no longer rendered
509        assert!(!post_popup.buffer.diff(post_close.buffer).is_empty());
510        assert!(pre_popup.buffer.diff(post_close.buffer).is_empty());
511    }
512
513    #[rstest]
514    fn test_move_with_search(song: Song) {
515        let (tx, _) = tokio::sync::mpsc::unbounded_channel();
516        let state = AppState::default();
517        let mut app = App::new(&state, tx);
518
519        let state = AppState {
520            search: SearchResult {
521                songs: vec![song].into_boxed_slice(),
522                ..Default::default()
523            },
524            ..state
525        };
526        app = app.move_with_search(&state);
527
528        assert_eq!(
529            app.content_view.search_view.props.search_results,
530            state.search,
531        );
532    }
533
534    #[rstest]
535    fn test_move_with_audio(song: Song) {
536        let (tx, _) = tokio::sync::mpsc::unbounded_channel();
537        let state = AppState::default();
538        let mut app = App::new(&state, tx);
539
540        let state = AppState {
541            audio: StateAudio {
542                queue: vec![song.clone()].into_boxed_slice(),
543                queue_position: Some(0),
544                current_song: Some(song.clone()),
545                repeat_mode: RepeatMode::One,
546                runtime: Some(StateRuntime {
547                    seek_position: Duration::from_secs(0),
548                    seek_percent: Percent::new(0.0),
549                    duration: song.runtime,
550                }),
551                status: Status::Stopped,
552                muted: false,
553                volume: 1.0,
554            },
555            ..state
556        };
557        app = app.move_with_audio(&state);
558
559        let components::queuebar::Props {
560            queue,
561            current_position,
562            repeat_mode,
563        } = app.queuebar.props;
564        assert_eq!(queue, state.audio.queue);
565        assert_eq!(current_position, state.audio.queue_position);
566        assert_eq!(repeat_mode, state.audio.repeat_mode);
567
568        let components::control_panel::Props {
569            is_playing,
570            muted,
571            volume,
572            song_runtime,
573            song_title,
574            song_artist,
575        } = app.control_panel.props;
576
577        assert_eq!(is_playing, !state.audio.paused());
578        assert_eq!(muted, state.audio.muted);
579        assert!(
580            f32::EPSILON > (volume - state.audio.volume).abs(),
581            "{} != {}",
582            volume,
583            state.audio.volume
584        );
585        assert_eq!(song_runtime, state.audio.runtime);
586        assert_eq!(
587            song_title,
588            state
589                .audio
590                .current_song
591                .as_ref()
592                .map(|song| song.title.to_string())
593        );
594        assert_eq!(
595            song_artist,
596            state.audio.current_song.as_ref().map(|song| {
597                song.artist
598                    .iter()
599                    .map(ToString::to_string)
600                    .collect::<Vec<String>>()
601                    .join(", ")
602            })
603        );
604    }
605
606    #[rstest]
607    fn test_move_with_library(song: Song) {
608        let (tx, _) = tokio::sync::mpsc::unbounded_channel();
609        let state = AppState {
610            active_component: ActiveComponent::ContentView,
611            active_view: ActiveView::Songs,
612            ..Default::default()
613        };
614        let mut app = App::new(&state, tx);
615
616        let state = AppState {
617            library: LibraryFull {
618                songs: vec![song].into_boxed_slice(),
619                ..Default::default()
620            },
621            ..state
622        };
623        app = app.move_with_library(&state);
624
625        assert_eq!(app.content_view.songs_view.props.songs, state.library.songs);
626    }
627
628    #[rstest]
629    fn test_move_with_view(song: Song) {
630        let (tx, _) = tokio::sync::mpsc::unbounded_channel();
631        let state = AppState {
632            active_component: ActiveComponent::ContentView,
633            active_view: ActiveView::Songs,
634            ..Default::default()
635        };
636        let mut app = App::new(&state, tx);
637
638        let state = AppState {
639            active_view: ActiveView::Song(song.id.key().to_owned().into()),
640            ..state
641        };
642        app = app.move_with_view(&state);
643
644        assert_eq!(app.content_view.props.active_view, state.active_view);
645    }
646
647    #[test]
648    fn test_move_with_component() {
649        let (tx, _) = tokio::sync::mpsc::unbounded_channel();
650        let app = App::new(&AppState::default(), tx);
651
652        assert_eq!(app.active_component, ActiveComponent::Sidebar);
653
654        let state = AppState {
655            active_component: ActiveComponent::QueueBar,
656            ..Default::default()
657        };
658        let app = app.move_with_component(&state);
659
660        assert_eq!(app.active_component, ActiveComponent::QueueBar);
661    }
662
663    #[rstest]
664    fn test_move_with_popup() {
665        let (tx, _) = tokio::sync::mpsc::unbounded_channel();
666        let app = App::new(&AppState::default(), tx);
667
668        assert!(app.popup.is_none());
669
670        let popup = Box::new(Notification::new(
671            "Hello, World!".into(),
672            unbounded_channel().0,
673        ));
674        let app = app.move_with_popup(Some(popup));
675
676        assert!(app.popup.is_some());
677    }
678
679    #[rstest]
680    #[case::sidebar(ActiveComponent::Sidebar)]
681    #[case::content_view(ActiveComponent::ContentView)]
682    #[case::queuebar(ActiveComponent::QueueBar)]
683    #[case::control_panel(ActiveComponent::ControlPanel)]
684    fn test_get_active_view_component(#[case] active_component: ActiveComponent) {
685        let (tx, _) = tokio::sync::mpsc::unbounded_channel();
686        let state = AppState {
687            active_component,
688            ..Default::default()
689        };
690        let app = App::new(&state, tx.clone());
691
692        let component = app.get_active_view_component();
693
694        match active_component {
695            ActiveComponent::Sidebar => assert_eq!(component.name(), "Sidebar"),
696            ActiveComponent::ContentView => assert_eq!(component.name(), "None"), // default content view is the None view, and it defers it's `name()` to the active view
697            ActiveComponent::QueueBar => assert_eq!(component.name(), "Queue"),
698            ActiveComponent::ControlPanel => assert_eq!(component.name(), "ControlPanel"),
699        }
700
701        // assert that the two "get_active_view_component" methods return the same component
702        assert_eq!(
703            component.name(),
704            App::new(&state, tx,).get_active_view_component_mut().name()
705        );
706    }
707
708    #[test]
709    fn test_click_to_focus() {
710        let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
711        let mut app = App::new(&AppState::default(), tx);
712
713        let (mut terminal, area) = setup_test_terminal(100, 100);
714        let _frame = terminal.draw(|frame| app.render(frame, area)).unwrap();
715
716        let mouse = crossterm::event::MouseEvent {
717            kind: crossterm::event::MouseEventKind::Down(crossterm::event::MouseButton::Left),
718            column: 2,
719            row: 2,
720            modifiers: KeyModifiers::empty(),
721        };
722        app.handle_mouse_event(mouse, area);
723
724        let action = rx.blocking_recv().unwrap();
725        assert_eq!(
726            action,
727            Action::ActiveComponent(ComponentAction::Set(ActiveComponent::Sidebar))
728        );
729
730        let mouse = crossterm::event::MouseEvent {
731            kind: crossterm::event::MouseEventKind::Down(crossterm::event::MouseButton::Left),
732            column: 50,
733            row: 10,
734            modifiers: KeyModifiers::empty(),
735        };
736        app.handle_mouse_event(mouse, area);
737
738        let action = rx.blocking_recv().unwrap();
739        assert_eq!(
740            action,
741            Action::ActiveComponent(ComponentAction::Set(ActiveComponent::ContentView))
742        );
743
744        let mouse = crossterm::event::MouseEvent {
745            kind: crossterm::event::MouseEventKind::Down(crossterm::event::MouseButton::Left),
746            column: 90,
747            row: 10,
748            modifiers: KeyModifiers::empty(),
749        };
750        app.handle_mouse_event(mouse, area);
751
752        let action = rx.blocking_recv().unwrap();
753        assert_eq!(
754            action,
755            Action::ActiveComponent(ComponentAction::Set(ActiveComponent::QueueBar))
756        );
757
758        let mouse = crossterm::event::MouseEvent {
759            kind: crossterm::event::MouseEventKind::Down(crossterm::event::MouseButton::Left),
760            column: 60,
761            row: 98,
762            modifiers: KeyModifiers::empty(),
763        };
764        app.handle_mouse_event(mouse, area);
765
766        let action = rx.blocking_recv().unwrap();
767        assert_eq!(
768            action,
769            Action::ActiveComponent(ComponentAction::Set(ActiveComponent::ControlPanel))
770        );
771    }
772}