mecomp_tui/ui/components/content_view/views/
radio.rs

1//! implementation of the radio view
2
3use std::sync::Mutex;
4
5use crossterm::event::{KeyCode, KeyEvent, MouseEvent};
6use ratatui::{
7    Frame,
8    layout::{Alignment, Margin, Rect},
9    style::{Style, Stylize},
10    text::{Line, Span},
11    widgets::{Block, Borders, Scrollbar, ScrollbarOrientation},
12};
13use tokio::sync::mpsc::UnboundedSender;
14
15use super::{RadioViewProps, checktree_utils::create_song_tree_leaf};
16use crate::{
17    state::action::{Action, AudioAction, PopupAction, QueueAction, ViewAction},
18    ui::{
19        AppState,
20        colors::{TEXT_HIGHLIGHT, TEXT_NORMAL, border_color},
21        components::{Component, ComponentRender, RenderProps},
22        widgets::{
23            popups::PopupType,
24            tree::{CheckTree, state::CheckTreeState},
25        },
26    },
27};
28
29#[allow(clippy::module_name_repetitions)]
30pub struct RadioView {
31    /// Action Sender
32    pub action_tx: UnboundedSender<Action>,
33    /// Mapped Props from state
34    pub props: Option<RadioViewProps>,
35    /// tree state
36    tree_state: Mutex<CheckTreeState<String>>,
37}
38
39impl Component for RadioView {
40    fn new(state: &AppState, action_tx: UnboundedSender<Action>) -> Self
41    where
42        Self: Sized,
43    {
44        Self {
45            action_tx,
46            props: state.additional_view_data.radio.clone(),
47            tree_state: Mutex::new(CheckTreeState::default()),
48        }
49    }
50
51    fn move_with_state(self, state: &AppState) -> Self
52    where
53        Self: Sized,
54    {
55        if let Some(props) = &state.additional_view_data.radio {
56            Self {
57                props: Some(props.to_owned()),
58                tree_state: Mutex::new(CheckTreeState::default()),
59                ..self
60            }
61        } else {
62            self
63        }
64    }
65
66    fn name(&self) -> &'static str {
67        "Radio"
68    }
69
70    fn handle_key_event(&mut self, key: KeyEvent) {
71        match key.code {
72            // arrow keys
73            KeyCode::PageUp => {
74                self.tree_state.lock().unwrap().select_relative(|current| {
75                    current.map_or(
76                        self.props
77                            .as_ref()
78                            .map_or(0, |p| p.songs.len().saturating_sub(1)),
79                        |c| c.saturating_sub(10),
80                    )
81                });
82            }
83            KeyCode::Up => {
84                self.tree_state.lock().unwrap().key_up();
85            }
86            KeyCode::PageDown => {
87                self.tree_state
88                    .lock()
89                    .unwrap()
90                    .select_relative(|current| current.map_or(0, |c| c.saturating_add(10)));
91            }
92            KeyCode::Down => {
93                self.tree_state.lock().unwrap().key_down();
94            }
95            KeyCode::Left => {
96                self.tree_state.lock().unwrap().key_left();
97            }
98            KeyCode::Right => {
99                self.tree_state.lock().unwrap().key_right();
100            }
101            KeyCode::Char(' ') => {
102                self.tree_state.lock().unwrap().key_space();
103            }
104            // Enter key opens selected view
105            KeyCode::Enter => {
106                if self.tree_state.lock().unwrap().toggle_selected() {
107                    let things = self.tree_state.lock().unwrap().get_selected_thing();
108
109                    if let Some(thing) = things {
110                        self.action_tx
111                            .send(Action::ActiveView(ViewAction::Set(thing.into())))
112                            .unwrap();
113                    }
114                }
115            }
116            // if there are checked items, send to queue, otherwise send whole radio to queue
117            KeyCode::Char('q') => {
118                let things = self.tree_state.lock().unwrap().get_checked_things();
119                if !things.is_empty() {
120                    self.action_tx
121                        .send(Action::Audio(AudioAction::Queue(QueueAction::Add(things))))
122                        .unwrap();
123                } else if let Some(props) = &self.props {
124                    self.action_tx
125                        .send(Action::Audio(AudioAction::Queue(QueueAction::Add(
126                            props.songs.iter().map(|s| s.id.clone().into()).collect(),
127                        ))))
128                        .expect("failed to send action");
129                }
130            }
131            // if there are checked items, add to playlist, otherwise add whole radio to playlist
132            KeyCode::Char('p') => {
133                let things = self.tree_state.lock().unwrap().get_checked_things();
134                if !things.is_empty() {
135                    self.action_tx
136                        .send(Action::Popup(PopupAction::Open(PopupType::Playlist(
137                            things,
138                        ))))
139                        .unwrap();
140                } else if let Some(props) = &self.props {
141                    self.action_tx
142                        .send(Action::Popup(PopupAction::Open(PopupType::Playlist(
143                            props.songs.iter().map(|s| s.id.clone().into()).collect(),
144                        ))))
145                        .expect("failed to send action");
146                }
147            }
148            _ => {}
149        }
150    }
151
152    fn handle_mouse_event(&mut self, mouse: MouseEvent, area: Rect) {
153        // adjust the area to account for the border
154        let area = area.inner(Margin::new(1, 1));
155        let area = Rect {
156            y: area.y + 2,
157            height: area.height - 2,
158            ..area
159        };
160
161        let result = self
162            .tree_state
163            .lock()
164            .unwrap()
165            .handle_mouse_event(mouse, area);
166        if let Some(action) = result {
167            self.action_tx.send(action).unwrap();
168        }
169    }
170}
171
172impl ComponentRender<RenderProps> for RadioView {
173    fn render_border(&self, frame: &mut Frame, props: RenderProps) -> RenderProps {
174        let border_style = Style::default().fg(border_color(props.is_focused).into());
175
176        let area = if let Some(state) = &self.props {
177            let border = Block::bordered()
178                .title_top(Line::from(vec![
179                    Span::styled("Radio", Style::default().bold()),
180                    Span::raw(" "),
181                    Span::styled(format!("top {}", state.count), Style::default().italic()),
182                ]))
183                .title_bottom(" \u{23CE} : Open | ←/↑/↓/→: Navigate | \u{2423} Check")
184                .border_style(border_style);
185            frame.render_widget(&border, props.area);
186            let content_area = border.inner(props.area);
187
188            // create an additional border around the content area to display additional instructions
189            let border = Block::default()
190                .borders(Borders::TOP)
191                .title_top("q: add to queue | p: add to playlist")
192                .border_style(border_style);
193            frame.render_widget(&border, content_area);
194            let content_area = border.inner(content_area);
195
196            // draw an additional border around the content area to indicate whether operations will be performed on the entire item, or just the checked items
197            let border = Block::default()
198                .borders(Borders::TOP)
199                .title_top(Line::from(vec![
200                    Span::raw("Performing operations on "),
201                    Span::raw(
202                        if self
203                            .tree_state
204                            .lock()
205                            .unwrap()
206                            .get_checked_things()
207                            .is_empty()
208                        {
209                            "entire radio"
210                        } else {
211                            "checked items"
212                        },
213                    )
214                    .fg(TEXT_HIGHLIGHT),
215                ]))
216                .italic()
217                .border_style(border_style);
218            frame.render_widget(&border, content_area);
219            border.inner(content_area)
220        } else {
221            let border = Block::bordered()
222                .title_top("Radio")
223                .border_style(border_style);
224            frame.render_widget(&border, props.area);
225            border.inner(props.area)
226        };
227
228        RenderProps { area, ..props }
229    }
230
231    fn render_content(&self, frame: &mut Frame, props: RenderProps) {
232        if let Some(state) = &self.props {
233            // create a tree to hold the radio results
234            let items = state
235                .songs
236                .iter()
237                .map(create_song_tree_leaf)
238                .collect::<Vec<_>>();
239
240            // render the radio results
241            frame.render_stateful_widget(
242                CheckTree::new(&items)
243                    .unwrap()
244                    .highlight_style(Style::default().fg(TEXT_HIGHLIGHT.into()).bold())
245                    .experimental_scrollbar(Some(Scrollbar::new(
246                        ScrollbarOrientation::VerticalRight,
247                    ))),
248                props.area,
249                &mut self.tree_state.lock().unwrap(),
250            );
251        } else {
252            let text = "Empty Radio";
253
254            frame.render_widget(
255                Line::from(text)
256                    .style(Style::default().fg(TEXT_NORMAL.into()))
257                    .alignment(Alignment::Center),
258                props.area,
259            );
260        }
261    }
262}
263
264#[cfg(test)]
265mod tests {
266    use super::*;
267    use crate::{
268        test_utils::{assert_buffer_eq, item_id, setup_test_terminal, state_with_everything},
269        ui::components::content_view::ActiveView,
270    };
271    use crossterm::event::{KeyModifiers, MouseButton, MouseEventKind};
272    use pretty_assertions::assert_eq;
273    use ratatui::buffer::Buffer;
274
275    #[test]
276    fn test_new() {
277        let (tx, _) = tokio::sync::mpsc::unbounded_channel();
278        let state = state_with_everything();
279        let view = RadioView::new(&state, tx);
280
281        assert_eq!(view.name(), "Radio");
282        assert_eq!(view.props, Some(state.additional_view_data.radio.unwrap()));
283    }
284
285    #[test]
286    fn test_move_with_state() {
287        let (tx, _) = tokio::sync::mpsc::unbounded_channel();
288        let state = AppState::default();
289        let new_state = state_with_everything();
290        let view = RadioView::new(&state, tx).move_with_state(&new_state);
291
292        assert_eq!(
293            view.props,
294            Some(new_state.additional_view_data.radio.unwrap())
295        );
296    }
297
298    #[test]
299    fn test_render_empty() {
300        let (tx, _) = tokio::sync::mpsc::unbounded_channel();
301        let view = RadioView::new(&AppState::default(), tx);
302
303        let (mut terminal, area) = setup_test_terminal(16, 3);
304        let props = RenderProps {
305            area,
306            is_focused: true,
307        };
308        let buffer = terminal
309            .draw(|frame| view.render(frame, props))
310            .unwrap()
311            .buffer
312            .clone();
313        #[rustfmt::skip]
314        let expected = Buffer::with_lines([
315            "┌Radio─────────┐",
316            "│ Empty Radio  │",
317            "└──────────────┘",
318        ]);
319
320        assert_buffer_eq(&buffer, &expected);
321    }
322
323    #[test]
324    fn test_render() {
325        let (tx, _) = tokio::sync::mpsc::unbounded_channel();
326        let view = RadioView::new(&state_with_everything(), tx);
327
328        let (mut terminal, area) = setup_test_terminal(50, 6);
329        let props = RenderProps {
330            area,
331            is_focused: true,
332        };
333        let buffer = terminal
334            .draw(|frame| view.render(frame, props))
335            .unwrap()
336            .buffer
337            .clone();
338        let expected = Buffer::with_lines([
339            "┌Radio top 1─────────────────────────────────────┐",
340            "│q: add to queue | p: add to playlist────────────│",
341            "│Performing operations on entire radio───────────│",
342            "│☐ Test Song Test Artist                         │",
343            "│                                                │",
344            "└ ⏎ : Open | ←/↑/↓/→: Navigate | ␣ Check─────────┘",
345        ]);
346
347        assert_buffer_eq(&buffer, &expected);
348    }
349
350    #[test]
351    fn test_render_with_checked() {
352        let (tx, _) = tokio::sync::mpsc::unbounded_channel();
353        let mut view = RadioView::new(&state_with_everything(), tx);
354        let (mut terminal, area) = setup_test_terminal(50, 6);
355        let props = RenderProps {
356            area,
357            is_focused: true,
358        };
359        let buffer = terminal
360            .draw(|frame| view.render(frame, props))
361            .unwrap()
362            .buffer
363            .clone();
364        let expected = Buffer::with_lines([
365            "┌Radio top 1─────────────────────────────────────┐",
366            "│q: add to queue | p: add to playlist────────────│",
367            "│Performing operations on entire radio───────────│",
368            "│☐ Test Song Test Artist                         │",
369            "│                                                │",
370            "└ ⏎ : Open | ←/↑/↓/→: Navigate | ␣ Check─────────┘",
371        ]);
372        assert_buffer_eq(&buffer, &expected);
373
374        view.handle_key_event(KeyEvent::from(KeyCode::Down));
375        view.handle_key_event(KeyEvent::from(KeyCode::Char(' ')));
376
377        let buffer = terminal
378            .draw(|frame| view.render(frame, props))
379            .unwrap()
380            .buffer
381            .clone();
382        let expected = Buffer::with_lines([
383            "┌Radio top 1─────────────────────────────────────┐",
384            "│q: add to queue | p: add to playlist────────────│",
385            "│Performing operations on checked items──────────│",
386            "│☑ Test Song Test Artist                         │",
387            "│                                                │",
388            "└ ⏎ : Open | ←/↑/↓/→: Navigate | ␣ Check─────────┘",
389        ]);
390
391        assert_buffer_eq(&buffer, &expected);
392    }
393
394    #[test]
395    fn smoke_navigation() {
396        let (tx, _) = tokio::sync::mpsc::unbounded_channel();
397        let mut view = RadioView::new(&state_with_everything(), tx);
398
399        view.handle_key_event(KeyEvent::from(KeyCode::Up));
400        view.handle_key_event(KeyEvent::from(KeyCode::PageUp));
401        view.handle_key_event(KeyEvent::from(KeyCode::Down));
402        view.handle_key_event(KeyEvent::from(KeyCode::PageDown));
403        view.handle_key_event(KeyEvent::from(KeyCode::Left));
404        view.handle_key_event(KeyEvent::from(KeyCode::Right));
405    }
406
407    #[test]
408    fn test_actions() {
409        let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
410        let mut view = RadioView::new(&state_with_everything(), tx);
411
412        // need to render the view at least once to load the tree state
413        let (mut terminal, area) = setup_test_terminal(50, 6);
414        let props = RenderProps {
415            area,
416            is_focused: true,
417        };
418        let _frame = terminal.draw(|frame| view.render(frame, props)).unwrap();
419
420        // we test the actions when:
421        // there are no checked items
422        view.handle_key_event(KeyEvent::from(KeyCode::Char('q')));
423        assert_eq!(
424            rx.blocking_recv().unwrap(),
425            Action::Audio(AudioAction::Queue(QueueAction::Add(vec![
426                ("song", item_id()).into()
427            ])))
428        );
429        view.handle_key_event(KeyEvent::from(KeyCode::Char('p')));
430        assert_eq!(
431            rx.blocking_recv().unwrap(),
432            Action::Popup(PopupAction::Open(PopupType::Playlist(vec![
433                ("song", item_id()).into()
434            ])))
435        );
436
437        // there are checked items
438        // first we need to select an item (the album)
439        view.handle_key_event(KeyEvent::from(KeyCode::Down));
440        let _frame = terminal.draw(|frame| view.render(frame, props)).unwrap();
441
442        // open the selected view
443        view.handle_key_event(KeyEvent::from(KeyCode::Enter));
444        assert_eq!(
445            rx.blocking_recv().unwrap(),
446            Action::ActiveView(ViewAction::Set(ActiveView::Song(item_id())))
447        );
448
449        // check the artist
450        view.handle_key_event(KeyEvent::from(KeyCode::Char(' ')));
451
452        // add to queue
453        view.handle_key_event(KeyEvent::from(KeyCode::Char('q')));
454        assert_eq!(
455            rx.blocking_recv().unwrap(),
456            Action::Audio(AudioAction::Queue(QueueAction::Add(vec![
457                ("song", item_id()).into()
458            ])))
459        );
460
461        // add to playlist
462        view.handle_key_event(KeyEvent::from(KeyCode::Char('p')));
463        assert_eq!(
464            rx.blocking_recv().unwrap(),
465            Action::Popup(PopupAction::Open(PopupType::Playlist(vec![
466                ("song", item_id()).into()
467            ])))
468        );
469    }
470
471    #[test]
472    fn test_mouse() {
473        let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
474        let mut view = RadioView::new(&state_with_everything(), tx);
475
476        // need to render the view at least once to load the tree state
477        let (mut terminal, area) = setup_test_terminal(50, 6);
478        let props = RenderProps {
479            area,
480            is_focused: true,
481        };
482        let buffer = terminal
483            .draw(|frame| view.render(frame, props))
484            .unwrap()
485            .buffer
486            .clone();
487        let expected = Buffer::with_lines([
488            "┌Radio top 1─────────────────────────────────────┐",
489            "│q: add to queue | p: add to playlist────────────│",
490            "│Performing operations on entire radio───────────│",
491            "│☐ Test Song Test Artist                         │",
492            "│                                                │",
493            "└ ⏎ : Open | ←/↑/↓/→: Navigate | ␣ Check─────────┘",
494        ]);
495        assert_buffer_eq(&buffer, &expected);
496
497        // scroll down
498        view.handle_mouse_event(
499            MouseEvent {
500                kind: MouseEventKind::ScrollDown,
501                column: 2,
502                row: 3,
503                modifiers: KeyModifiers::empty(),
504            },
505            area,
506        );
507
508        // click on the first item
509        view.handle_mouse_event(
510            MouseEvent {
511                kind: MouseEventKind::Down(MouseButton::Left),
512                column: 2,
513                row: 3,
514                modifiers: KeyModifiers::empty(),
515            },
516            area,
517        );
518        assert_eq!(
519            rx.blocking_recv().unwrap(),
520            Action::ActiveView(ViewAction::Set(ActiveView::Song(item_id())))
521        );
522        let buffer = terminal
523            .draw(|frame| view.render(frame, props))
524            .unwrap()
525            .buffer
526            .clone();
527        let expected = Buffer::with_lines([
528            "┌Radio top 1─────────────────────────────────────┐",
529            "│q: add to queue | p: add to playlist────────────│",
530            "│Performing operations on checked items──────────│",
531            "│☑ Test Song Test Artist                         │",
532            "│                                                │",
533            "└ ⏎ : Open | ←/↑/↓/→: Navigate | ␣ Check─────────┘",
534        ]);
535        assert_buffer_eq(&buffer, &expected);
536
537        // scroll up
538        view.handle_mouse_event(
539            MouseEvent {
540                kind: MouseEventKind::ScrollUp,
541                column: 2,
542                row: 3,
543                modifiers: KeyModifiers::empty(),
544            },
545            area,
546        );
547
548        // clicking on an empty area should clear the selection
549        let mouse = MouseEvent {
550            kind: MouseEventKind::Down(MouseButton::Left),
551            column: 2,
552            row: 4,
553            modifiers: KeyModifiers::empty(),
554        };
555        view.handle_mouse_event(mouse, area);
556        assert_eq!(view.tree_state.lock().unwrap().get_selected_thing(), None);
557        view.handle_mouse_event(mouse, area);
558        assert_eq!(
559            rx.try_recv(),
560            Err(tokio::sync::mpsc::error::TryRecvError::Empty)
561        );
562    }
563}