Skip to main content

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