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::LibraryBrief},
374    };
375    use mecomp_storage::db::schemas::song::{Song, SongBrief};
376    use pretty_assertions::assert_eq;
377    use rstest::{fixture, rstest};
378    use tokio::sync::mpsc::unbounded_channel;
379
380    #[fixture]
381    fn song() -> SongBrief {
382        SongBrief {
383            id: Song::generate_id(),
384            title: "Test Song".into(),
385            artist: "Test Artist".to_string().into(),
386            album_artist: "Test Album Artist".to_string().into(),
387            album: "Test Album".into(),
388            genre: "Test Genre".to_string().into(),
389            runtime: Duration::from_secs(180),
390            track: Some(0),
391            disc: Some(0),
392            release_year: Some(2021),
393            extension: "mp3".into(),
394            path: "test.mp3".into(),
395        }
396    }
397
398    #[rstest]
399    #[case::tab(KeyCode::Tab, Action::ActiveComponent(ComponentAction::Next))]
400    #[case::back_tab(KeyCode::BackTab, Action::ActiveComponent(ComponentAction::Previous))]
401    #[case::esc(KeyCode::Esc, Action::General(GeneralAction::Exit))]
402    fn test_actions(#[case] key_code: KeyCode, #[case] expected: Action) {
403        let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
404        let mut app = App::new(&AppState::default(), tx);
405
406        app.handle_key_event(KeyEvent::from(key_code));
407
408        let action = rx.blocking_recv().unwrap();
409
410        assert_eq!(action, expected);
411    }
412
413    #[rstest]
414    #[case::sidebar(ActiveComponent::Sidebar)]
415    #[case::content_view(ActiveComponent::ContentView)]
416    #[case::queuebar(ActiveComponent::QueueBar)]
417    #[case::control_panel(ActiveComponent::ControlPanel)]
418    fn smoke_render(#[case] active_component: ActiveComponent) {
419        let (tx, _) = tokio::sync::mpsc::unbounded_channel();
420        let app = App::new(
421            &AppState {
422                active_component,
423                ..Default::default()
424            },
425            tx,
426        );
427
428        let (mut terminal, area) = setup_test_terminal(100, 100);
429        let completed_frame = terminal.draw(|frame| app.render(frame, area));
430
431        assert!(completed_frame.is_ok());
432    }
433
434    #[rstest]
435    #[case::sidebar(ActiveComponent::Sidebar)]
436    #[case::content_view(ActiveComponent::ContentView)]
437    #[case::queuebar(ActiveComponent::QueueBar)]
438    #[case::control_panel(ActiveComponent::ControlPanel)]
439    fn test_render_with_popup(#[case] active_component: ActiveComponent) {
440        let (tx, _) = tokio::sync::mpsc::unbounded_channel();
441        let app = App::new(
442            &AppState {
443                active_component,
444                ..Default::default()
445            },
446            tx,
447        );
448
449        let (mut terminal, area) = setup_test_terminal(100, 100);
450        let pre_popup = terminal.draw(|frame| app.render(frame, area)).unwrap();
451
452        let app = app.move_with_popup(Some(Box::new(Notification::new(
453            "Hello, World!".into(),
454            unbounded_channel().0,
455        ))));
456
457        let (mut terminal, area) = setup_test_terminal(100, 100);
458        let post_popup = terminal.draw(|frame| app.render(frame, area)).unwrap();
459
460        assert!(!pre_popup.buffer.diff(post_popup.buffer).is_empty());
461    }
462
463    #[rstest]
464    #[case::sidebar(ActiveComponent::Sidebar)]
465    #[case::content_view(ActiveComponent::ContentView)]
466    #[case::queuebar(ActiveComponent::QueueBar)]
467    #[case::control_panel(ActiveComponent::ControlPanel)]
468    #[tokio::test]
469    async fn test_popup_takes_over_key_events(#[case] active_component: ActiveComponent) {
470        let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
471        let mut app = App::new(
472            &AppState {
473                active_component,
474                ..Default::default()
475            },
476            tx,
477        );
478
479        let (mut terminal, area) = setup_test_terminal(100, 100);
480        let pre_popup = terminal.draw(|frame| app.render(frame, area)).unwrap();
481
482        let popup = Box::new(Notification::new(
483            "Hello, World!".into(),
484            unbounded_channel().0,
485        ));
486        app = app.move_with_popup(Some(popup));
487
488        let (mut terminal, area) = setup_test_terminal(100, 100);
489        let post_popup = terminal.draw(|frame| app.render(frame, area)).unwrap();
490
491        // assert that the popup is rendered
492        assert!(!pre_popup.buffer.diff(post_popup.buffer).is_empty());
493
494        // now, send a Esc key event to the app
495        app.handle_key_event(KeyEvent::from(KeyCode::Esc));
496
497        // assert that we received a close popup action
498        let action = rx.recv().await.unwrap();
499        assert_eq!(action, Action::Popup(PopupAction::Close));
500
501        // close the popup (the action handler isn't running so we have to do it manually)
502        app = app.move_with_popup(None);
503
504        let (mut terminal, area) = setup_test_terminal(100, 100);
505        let post_close = terminal.draw(|frame| app.render(frame, area)).unwrap();
506
507        // assert that the popup is no longer rendered
508        assert!(!post_popup.buffer.diff(post_close.buffer).is_empty());
509        assert!(pre_popup.buffer.diff(post_close.buffer).is_empty());
510    }
511
512    #[rstest]
513    fn test_move_with_search(song: SongBrief) {
514        let (tx, _) = tokio::sync::mpsc::unbounded_channel();
515        let state = AppState::default();
516        let mut app = App::new(&state, tx);
517
518        let state = AppState {
519            search: SearchResult {
520                songs: vec![song].into_boxed_slice(),
521                ..Default::default()
522            },
523            ..state
524        };
525        app = app.move_with_search(&state);
526
527        assert_eq!(
528            app.content_view.search_view.props.search_results,
529            state.search,
530        );
531    }
532
533    #[rstest]
534    fn test_move_with_audio(song: SongBrief) {
535        let (tx, _) = tokio::sync::mpsc::unbounded_channel();
536        let state = AppState::default();
537        let mut app = App::new(&state, tx);
538
539        let state = AppState {
540            audio: StateAudio {
541                queue: vec![song.clone()].into_boxed_slice(),
542                queue_position: Some(0),
543                current_song: Some(song.clone()),
544                repeat_mode: RepeatMode::One,
545                runtime: Some(StateRuntime {
546                    seek_position: Duration::from_secs(0),
547                    seek_percent: Percent::new(0.0),
548                    duration: song.runtime,
549                }),
550                status: Status::Stopped,
551                muted: false,
552                volume: 1.0,
553            },
554            ..state
555        };
556        app = app.move_with_audio(&state);
557
558        let components::queuebar::Props {
559            queue,
560            current_position,
561            repeat_mode,
562        } = app.queuebar.props;
563        assert_eq!(queue, state.audio.queue);
564        assert_eq!(current_position, state.audio.queue_position);
565        assert_eq!(repeat_mode, state.audio.repeat_mode);
566
567        let components::control_panel::Props {
568            is_playing,
569            muted,
570            volume,
571            song_runtime,
572            song_title,
573            song_artist,
574        } = app.control_panel.props;
575
576        assert_eq!(is_playing, !state.audio.paused());
577        assert_eq!(muted, state.audio.muted);
578        assert!(
579            f32::EPSILON > (volume - state.audio.volume).abs(),
580            "{} != {}",
581            volume,
582            state.audio.volume
583        );
584        assert_eq!(song_runtime, state.audio.runtime);
585        assert_eq!(
586            song_title,
587            state
588                .audio
589                .current_song
590                .as_ref()
591                .map(|song| song.title.to_string())
592        );
593        assert_eq!(
594            song_artist,
595            state.audio.current_song.as_ref().map(|song| {
596                song.artist
597                    .iter()
598                    .map(ToString::to_string)
599                    .collect::<Vec<String>>()
600                    .join(", ")
601            })
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_boxed_slice(),
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_owned().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}