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    layout::{Alignment, Margin, Rect},
8    style::{Style, Stylize},
9    text::{Line, Span},
10    widgets::{Block, Borders, Scrollbar, ScrollbarOrientation},
11    Frame,
12};
13use tokio::sync::mpsc::UnboundedSender;
14
15use super::{checktree_utils::create_song_tree_leaf, RadioViewProps};
16use crate::{
17    state::action::{Action, AudioAction, PopupAction, QueueAction, ViewAction},
18    ui::{
19        colors::{border_color, TEXT_HIGHLIGHT, TEXT_NORMAL},
20        components::{Component, ComponentRender, RenderProps},
21        widgets::{
22            popups::PopupType,
23            tree::{state::CheckTreeState, CheckTree},
24        },
25        AppState,
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 anyhow::Result;
272    use crossterm::event::{KeyModifiers, MouseButton, MouseEventKind};
273    use pretty_assertions::assert_eq;
274    use ratatui::buffer::Buffer;
275
276    #[test]
277    fn test_new() {
278        let (tx, _) = tokio::sync::mpsc::unbounded_channel();
279        let state = state_with_everything();
280        let view = RadioView::new(&state, tx);
281
282        assert_eq!(view.name(), "Radio");
283        assert_eq!(view.props, Some(state.additional_view_data.radio.unwrap()));
284    }
285
286    #[test]
287    fn test_move_with_state() {
288        let (tx, _) = tokio::sync::mpsc::unbounded_channel();
289        let state = AppState::default();
290        let new_state = state_with_everything();
291        let view = RadioView::new(&state, tx).move_with_state(&new_state);
292
293        assert_eq!(
294            view.props,
295            Some(new_state.additional_view_data.radio.unwrap())
296        );
297    }
298
299    #[test]
300    fn test_render_empty() -> Result<()> {
301        let (tx, _) = tokio::sync::mpsc::unbounded_channel();
302        let view = RadioView::new(&AppState::default(), tx);
303
304        let (mut terminal, area) = setup_test_terminal(16, 3);
305        let props = RenderProps {
306            area,
307            is_focused: true,
308        };
309        let buffer = terminal
310            .draw(|frame| view.render(frame, props))
311            .unwrap()
312            .buffer
313            .clone();
314        #[rustfmt::skip]
315        let expected = Buffer::with_lines([
316            "┌Radio─────────┐",
317            "│ Empty Radio  │",
318            "└──────────────┘",
319        ]);
320
321        assert_buffer_eq(&buffer, &expected);
322
323        Ok(())
324    }
325
326    #[test]
327    fn test_render() -> Result<()> {
328        let (tx, _) = tokio::sync::mpsc::unbounded_channel();
329        let view = RadioView::new(&state_with_everything(), tx);
330
331        let (mut terminal, area) = setup_test_terminal(50, 6);
332        let props = RenderProps {
333            area,
334            is_focused: true,
335        };
336        let buffer = terminal
337            .draw(|frame| view.render(frame, props))
338            .unwrap()
339            .buffer
340            .clone();
341        let expected = Buffer::with_lines([
342            "┌Radio top 1─────────────────────────────────────┐",
343            "│q: add to queue | p: add to playlist────────────│",
344            "│Performing operations on entire radio───────────│",
345            "│☐ Test Song Test Artist                         │",
346            "│                                                │",
347            "└ ⏎ : Open | ←/↑/↓/→: Navigate | ␣ Check─────────┘",
348        ]);
349
350        assert_buffer_eq(&buffer, &expected);
351
352        Ok(())
353    }
354
355    #[test]
356    fn test_render_with_checked() -> Result<()> {
357        let (tx, _) = tokio::sync::mpsc::unbounded_channel();
358        let mut view = RadioView::new(&state_with_everything(), tx);
359        let (mut terminal, area) = setup_test_terminal(50, 6);
360        let props = RenderProps {
361            area,
362            is_focused: true,
363        };
364        let buffer = terminal
365            .draw(|frame| view.render(frame, props))
366            .unwrap()
367            .buffer
368            .clone();
369        let expected = Buffer::with_lines([
370            "┌Radio top 1─────────────────────────────────────┐",
371            "│q: add to queue | p: add to playlist────────────│",
372            "│Performing operations on entire radio───────────│",
373            "│☐ Test Song Test Artist                         │",
374            "│                                                │",
375            "└ ⏎ : Open | ←/↑/↓/→: Navigate | ␣ Check─────────┘",
376        ]);
377        assert_buffer_eq(&buffer, &expected);
378
379        view.handle_key_event(KeyEvent::from(KeyCode::Down));
380        view.handle_key_event(KeyEvent::from(KeyCode::Char(' ')));
381
382        let buffer = terminal
383            .draw(|frame| view.render(frame, props))
384            .unwrap()
385            .buffer
386            .clone();
387        let expected = Buffer::with_lines([
388            "┌Radio top 1─────────────────────────────────────┐",
389            "│q: add to queue | p: add to playlist────────────│",
390            "│Performing operations on checked items──────────│",
391            "│☑ Test Song Test Artist                         │",
392            "│                                                │",
393            "└ ⏎ : Open | ←/↑/↓/→: Navigate | ␣ Check─────────┘",
394        ]);
395
396        assert_buffer_eq(&buffer, &expected);
397
398        Ok(())
399    }
400
401    #[test]
402    fn smoke_navigation() {
403        let (tx, _) = tokio::sync::mpsc::unbounded_channel();
404        let mut view = RadioView::new(&state_with_everything(), tx);
405
406        view.handle_key_event(KeyEvent::from(KeyCode::Up));
407        view.handle_key_event(KeyEvent::from(KeyCode::PageUp));
408        view.handle_key_event(KeyEvent::from(KeyCode::Down));
409        view.handle_key_event(KeyEvent::from(KeyCode::PageDown));
410        view.handle_key_event(KeyEvent::from(KeyCode::Left));
411        view.handle_key_event(KeyEvent::from(KeyCode::Right));
412    }
413
414    #[test]
415    fn test_actions() {
416        let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
417        let mut view = RadioView::new(&state_with_everything(), tx);
418
419        // need to render the view at least once to load the tree state
420        let (mut terminal, area) = setup_test_terminal(50, 6);
421        let props = RenderProps {
422            area,
423            is_focused: true,
424        };
425        let _frame = terminal.draw(|frame| view.render(frame, props)).unwrap();
426
427        // we test the actions when:
428        // there are no checked items
429        view.handle_key_event(KeyEvent::from(KeyCode::Char('q')));
430        assert_eq!(
431            rx.blocking_recv().unwrap(),
432            Action::Audio(AudioAction::Queue(QueueAction::Add(vec![(
433                "song",
434                item_id()
435            )
436                .into()])))
437        );
438        view.handle_key_event(KeyEvent::from(KeyCode::Char('p')));
439        assert_eq!(
440            rx.blocking_recv().unwrap(),
441            Action::Popup(PopupAction::Open(PopupType::Playlist(vec![(
442                "song",
443                item_id()
444            )
445                .into()])))
446        );
447
448        // there are checked items
449        // first we need to select an item (the album)
450        view.handle_key_event(KeyEvent::from(KeyCode::Down));
451        let _frame = terminal.draw(|frame| view.render(frame, props)).unwrap();
452
453        // open the selected view
454        view.handle_key_event(KeyEvent::from(KeyCode::Enter));
455        assert_eq!(
456            rx.blocking_recv().unwrap(),
457            Action::ActiveView(ViewAction::Set(ActiveView::Song(item_id())))
458        );
459
460        // check the artist
461        view.handle_key_event(KeyEvent::from(KeyCode::Char(' ')));
462
463        // add to queue
464        view.handle_key_event(KeyEvent::from(KeyCode::Char('q')));
465        assert_eq!(
466            rx.blocking_recv().unwrap(),
467            Action::Audio(AudioAction::Queue(QueueAction::Add(vec![(
468                "song",
469                item_id()
470            )
471                .into()])))
472        );
473
474        // add to playlist
475        view.handle_key_event(KeyEvent::from(KeyCode::Char('p')));
476        assert_eq!(
477            rx.blocking_recv().unwrap(),
478            Action::Popup(PopupAction::Open(PopupType::Playlist(vec![(
479                "song",
480                item_id()
481            )
482                .into()])))
483        );
484    }
485
486    #[test]
487    fn test_mouse() {
488        let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
489        let mut view = RadioView::new(&state_with_everything(), tx);
490
491        // need to render the view at least once to load the tree state
492        let (mut terminal, area) = setup_test_terminal(50, 6);
493        let props = RenderProps {
494            area,
495            is_focused: true,
496        };
497        let buffer = terminal
498            .draw(|frame| view.render(frame, props))
499            .unwrap()
500            .buffer
501            .clone();
502        let expected = Buffer::with_lines([
503            "┌Radio top 1─────────────────────────────────────┐",
504            "│q: add to queue | p: add to playlist────────────│",
505            "│Performing operations on entire radio───────────│",
506            "│☐ Test Song Test Artist                         │",
507            "│                                                │",
508            "└ ⏎ : Open | ←/↑/↓/→: Navigate | ␣ Check─────────┘",
509        ]);
510        assert_buffer_eq(&buffer, &expected);
511
512        // scroll down
513        view.handle_mouse_event(
514            MouseEvent {
515                kind: MouseEventKind::ScrollDown,
516                column: 2,
517                row: 3,
518                modifiers: KeyModifiers::empty(),
519            },
520            area,
521        );
522
523        // click on the first item
524        view.handle_mouse_event(
525            MouseEvent {
526                kind: MouseEventKind::Down(MouseButton::Left),
527                column: 2,
528                row: 3,
529                modifiers: KeyModifiers::empty(),
530            },
531            area,
532        );
533        assert_eq!(
534            rx.blocking_recv().unwrap(),
535            Action::ActiveView(ViewAction::Set(ActiveView::Song(item_id())))
536        );
537        let buffer = terminal
538            .draw(|frame| view.render(frame, props))
539            .unwrap()
540            .buffer
541            .clone();
542        let expected = Buffer::with_lines([
543            "┌Radio top 1─────────────────────────────────────┐",
544            "│q: add to queue | p: add to playlist────────────│",
545            "│Performing operations on checked items──────────│",
546            "│☑ Test Song Test Artist                         │",
547            "│                                                │",
548            "└ ⏎ : Open | ←/↑/↓/→: Navigate | ␣ Check─────────┘",
549        ]);
550        assert_buffer_eq(&buffer, &expected);
551
552        // scroll up
553        view.handle_mouse_event(
554            MouseEvent {
555                kind: MouseEventKind::ScrollUp,
556                column: 2,
557                row: 3,
558                modifiers: KeyModifiers::empty(),
559            },
560            area,
561        );
562
563        // clicking on an empty area should clear the selection
564        let mouse = MouseEvent {
565            kind: MouseEventKind::Down(MouseButton::Left),
566            column: 2,
567            row: 4,
568            modifiers: KeyModifiers::empty(),
569        };
570        view.handle_mouse_event(mouse, area);
571        assert_eq!(view.tree_state.lock().unwrap().get_selected_thing(), None);
572        view.handle_mouse_event(mouse, area);
573        assert_eq!(
574            rx.try_recv(),
575            Err(tokio::sync::mpsc::error::TryRecvError::Empty)
576        );
577    }
578}