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_FOCUSED, BORDER_UNFOCUSED, 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 = if props.is_focused {
175            Style::default().fg(BORDER_FOCUSED.into())
176        } else {
177            Style::default().fg(BORDER_UNFOCUSED.into())
178        };
179
180        let area = if let Some(state) = &self.props {
181            let border = Block::bordered()
182                .title_top(Line::from(vec![
183                    Span::styled("Radio", Style::default().bold()),
184                    Span::raw(" "),
185                    Span::styled(format!("top {}", state.count), Style::default().italic()),
186                ]))
187                .title_bottom(" \u{23CE} : Open | ←/↑/↓/→: Navigate | \u{2423} Check")
188                .border_style(border_style);
189            frame.render_widget(&border, props.area);
190            let content_area = border.inner(props.area);
191
192            // create an additional border around the content area to display additional instructions
193            let border = Block::default()
194                .borders(Borders::TOP)
195                .title_top("q: add to queue | p: add to playlist")
196                .border_style(border_style);
197            frame.render_widget(&border, content_area);
198            let content_area = border.inner(content_area);
199
200            // draw an additional border around the content area to indicate whether operations will be performed on the entire item, or just the checked items
201            let border = Block::default()
202                .borders(Borders::TOP)
203                .title_top(Line::from(vec![
204                    Span::raw("Performing operations on "),
205                    Span::raw(
206                        if self
207                            .tree_state
208                            .lock()
209                            .unwrap()
210                            .get_checked_things()
211                            .is_empty()
212                        {
213                            "entire radio"
214                        } else {
215                            "checked items"
216                        },
217                    )
218                    .fg(TEXT_HIGHLIGHT),
219                ]))
220                .italic()
221                .border_style(border_style);
222            frame.render_widget(&border, content_area);
223            border.inner(content_area)
224        } else {
225            let border = Block::bordered()
226                .title_top("Radio")
227                .border_style(border_style);
228            frame.render_widget(&border, props.area);
229            border.inner(props.area)
230        };
231
232        RenderProps { area, ..props }
233    }
234
235    fn render_content(&self, frame: &mut Frame, props: RenderProps) {
236        if let Some(state) = &self.props {
237            // create a tree to hold the radio results
238            let items = state
239                .songs
240                .iter()
241                .map(create_song_tree_leaf)
242                .collect::<Vec<_>>();
243
244            // render the radio results
245            frame.render_stateful_widget(
246                CheckTree::new(&items)
247                    .unwrap()
248                    .highlight_style(Style::default().fg(TEXT_HIGHLIGHT.into()).bold())
249                    .experimental_scrollbar(Some(Scrollbar::new(
250                        ScrollbarOrientation::VerticalRight,
251                    ))),
252                props.area,
253                &mut self.tree_state.lock().unwrap(),
254            );
255        } else {
256            let text = "Empty Radio";
257
258            frame.render_widget(
259                Line::from(text)
260                    .style(Style::default().fg(TEXT_NORMAL.into()))
261                    .alignment(Alignment::Center),
262                props.area,
263            );
264        }
265    }
266}
267
268#[cfg(test)]
269mod tests {
270    use super::*;
271    use crate::{
272        test_utils::{assert_buffer_eq, item_id, setup_test_terminal, state_with_everything},
273        ui::components::content_view::ActiveView,
274    };
275    use anyhow::Result;
276    use crossterm::event::{KeyModifiers, MouseButton, MouseEventKind};
277    use pretty_assertions::assert_eq;
278    use ratatui::buffer::Buffer;
279
280    #[test]
281    fn test_new() {
282        let (tx, _) = tokio::sync::mpsc::unbounded_channel();
283        let state = state_with_everything();
284        let view = RadioView::new(&state, tx);
285
286        assert_eq!(view.name(), "Radio");
287        assert_eq!(view.props, Some(state.additional_view_data.radio.unwrap()));
288    }
289
290    #[test]
291    fn test_move_with_state() {
292        let (tx, _) = tokio::sync::mpsc::unbounded_channel();
293        let state = AppState::default();
294        let new_state = state_with_everything();
295        let view = RadioView::new(&state, tx).move_with_state(&new_state);
296
297        assert_eq!(
298            view.props,
299            Some(new_state.additional_view_data.radio.unwrap())
300        );
301    }
302
303    #[test]
304    fn test_render_empty() -> Result<()> {
305        let (tx, _) = tokio::sync::mpsc::unbounded_channel();
306        let view = RadioView::new(&AppState::default(), tx);
307
308        let (mut terminal, area) = setup_test_terminal(16, 3);
309        let props = RenderProps {
310            area,
311            is_focused: true,
312        };
313        let buffer = terminal
314            .draw(|frame| view.render(frame, props))
315            .unwrap()
316            .buffer
317            .clone();
318        #[rustfmt::skip]
319        let expected = Buffer::with_lines([
320            "┌Radio─────────┐",
321            "│ Empty Radio  │",
322            "└──────────────┘",
323        ]);
324
325        assert_buffer_eq(&buffer, &expected);
326
327        Ok(())
328    }
329
330    #[test]
331    fn test_render() -> Result<()> {
332        let (tx, _) = tokio::sync::mpsc::unbounded_channel();
333        let view = RadioView::new(&state_with_everything(), tx);
334
335        let (mut terminal, area) = setup_test_terminal(50, 6);
336        let props = RenderProps {
337            area,
338            is_focused: true,
339        };
340        let buffer = terminal
341            .draw(|frame| view.render(frame, props))
342            .unwrap()
343            .buffer
344            .clone();
345        let expected = Buffer::with_lines([
346            "┌Radio top 1─────────────────────────────────────┐",
347            "│q: add to queue | p: add to playlist────────────│",
348            "│Performing operations on entire radio───────────│",
349            "│☐ Test Song Test Artist                         │",
350            "│                                                │",
351            "└ ⏎ : Open | ←/↑/↓/→: Navigate | ␣ Check─────────┘",
352        ]);
353
354        assert_buffer_eq(&buffer, &expected);
355
356        Ok(())
357    }
358
359    #[test]
360    fn test_render_with_checked() -> Result<()> {
361        let (tx, _) = tokio::sync::mpsc::unbounded_channel();
362        let mut view = RadioView::new(&state_with_everything(), tx);
363        let (mut terminal, area) = setup_test_terminal(50, 6);
364        let props = RenderProps {
365            area,
366            is_focused: true,
367        };
368        let buffer = terminal
369            .draw(|frame| view.render(frame, props))
370            .unwrap()
371            .buffer
372            .clone();
373        let expected = Buffer::with_lines([
374            "┌Radio top 1─────────────────────────────────────┐",
375            "│q: add to queue | p: add to playlist────────────│",
376            "│Performing operations on entire radio───────────│",
377            "│☐ Test Song Test Artist                         │",
378            "│                                                │",
379            "└ ⏎ : Open | ←/↑/↓/→: Navigate | ␣ Check─────────┘",
380        ]);
381        assert_buffer_eq(&buffer, &expected);
382
383        view.handle_key_event(KeyEvent::from(KeyCode::Down));
384        view.handle_key_event(KeyEvent::from(KeyCode::Char(' ')));
385
386        let buffer = terminal
387            .draw(|frame| view.render(frame, props))
388            .unwrap()
389            .buffer
390            .clone();
391        let expected = Buffer::with_lines([
392            "┌Radio top 1─────────────────────────────────────┐",
393            "│q: add to queue | p: add to playlist────────────│",
394            "│Performing operations on checked items──────────│",
395            "│☑ Test Song Test Artist                         │",
396            "│                                                │",
397            "└ ⏎ : Open | ←/↑/↓/→: Navigate | ␣ Check─────────┘",
398        ]);
399
400        assert_buffer_eq(&buffer, &expected);
401
402        Ok(())
403    }
404
405    #[test]
406    fn smoke_navigation() {
407        let (tx, _) = tokio::sync::mpsc::unbounded_channel();
408        let mut view = RadioView::new(&state_with_everything(), tx);
409
410        view.handle_key_event(KeyEvent::from(KeyCode::Up));
411        view.handle_key_event(KeyEvent::from(KeyCode::PageUp));
412        view.handle_key_event(KeyEvent::from(KeyCode::Down));
413        view.handle_key_event(KeyEvent::from(KeyCode::PageDown));
414        view.handle_key_event(KeyEvent::from(KeyCode::Left));
415        view.handle_key_event(KeyEvent::from(KeyCode::Right));
416    }
417
418    #[test]
419    fn test_actions() {
420        let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
421        let mut view = RadioView::new(&state_with_everything(), tx);
422
423        // need to render the view at least once to load the tree state
424        let (mut terminal, area) = setup_test_terminal(50, 6);
425        let props = RenderProps {
426            area,
427            is_focused: true,
428        };
429        let _frame = terminal.draw(|frame| view.render(frame, props)).unwrap();
430
431        // we test the actions when:
432        // there are no checked items
433        view.handle_key_event(KeyEvent::from(KeyCode::Char('q')));
434        assert_eq!(
435            rx.blocking_recv().unwrap(),
436            Action::Audio(AudioAction::Queue(QueueAction::Add(vec![(
437                "song",
438                item_id()
439            )
440                .into()])))
441        );
442        view.handle_key_event(KeyEvent::from(KeyCode::Char('p')));
443        assert_eq!(
444            rx.blocking_recv().unwrap(),
445            Action::Popup(PopupAction::Open(PopupType::Playlist(vec![(
446                "song",
447                item_id()
448            )
449                .into()])))
450        );
451
452        // there are checked items
453        // first we need to select an item (the album)
454        view.handle_key_event(KeyEvent::from(KeyCode::Down));
455        let _frame = terminal.draw(|frame| view.render(frame, props)).unwrap();
456
457        // open the selected view
458        view.handle_key_event(KeyEvent::from(KeyCode::Enter));
459        assert_eq!(
460            rx.blocking_recv().unwrap(),
461            Action::ActiveView(ViewAction::Set(ActiveView::Song(item_id())))
462        );
463
464        // check the artist
465        view.handle_key_event(KeyEvent::from(KeyCode::Char(' ')));
466
467        // add to queue
468        view.handle_key_event(KeyEvent::from(KeyCode::Char('q')));
469        assert_eq!(
470            rx.blocking_recv().unwrap(),
471            Action::Audio(AudioAction::Queue(QueueAction::Add(vec![(
472                "song",
473                item_id()
474            )
475                .into()])))
476        );
477
478        // add to playlist
479        view.handle_key_event(KeyEvent::from(KeyCode::Char('p')));
480        assert_eq!(
481            rx.blocking_recv().unwrap(),
482            Action::Popup(PopupAction::Open(PopupType::Playlist(vec![(
483                "song",
484                item_id()
485            )
486                .into()])))
487        );
488    }
489
490    #[test]
491    fn test_mouse() {
492        let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
493        let mut view = RadioView::new(&state_with_everything(), tx);
494
495        // need to render the view at least once to load the tree state
496        let (mut terminal, area) = setup_test_terminal(50, 6);
497        let props = RenderProps {
498            area,
499            is_focused: true,
500        };
501        let buffer = terminal
502            .draw(|frame| view.render(frame, props))
503            .unwrap()
504            .buffer
505            .clone();
506        let expected = Buffer::with_lines([
507            "┌Radio top 1─────────────────────────────────────┐",
508            "│q: add to queue | p: add to playlist────────────│",
509            "│Performing operations on entire radio───────────│",
510            "│☐ Test Song Test Artist                         │",
511            "│                                                │",
512            "└ ⏎ : Open | ←/↑/↓/→: Navigate | ␣ Check─────────┘",
513        ]);
514        assert_buffer_eq(&buffer, &expected);
515
516        // scroll down
517        view.handle_mouse_event(
518            MouseEvent {
519                kind: MouseEventKind::ScrollDown,
520                column: 2,
521                row: 3,
522                modifiers: KeyModifiers::empty(),
523            },
524            area,
525        );
526
527        // click on the first item
528        view.handle_mouse_event(
529            MouseEvent {
530                kind: MouseEventKind::Down(MouseButton::Left),
531                column: 2,
532                row: 3,
533                modifiers: KeyModifiers::empty(),
534            },
535            area,
536        );
537        assert_eq!(
538            rx.blocking_recv().unwrap(),
539            Action::ActiveView(ViewAction::Set(ActiveView::Song(item_id())))
540        );
541        let buffer = terminal
542            .draw(|frame| view.render(frame, props))
543            .unwrap()
544            .buffer
545            .clone();
546        let expected = Buffer::with_lines([
547            "┌Radio top 1─────────────────────────────────────┐",
548            "│q: add to queue | p: add to playlist────────────│",
549            "│Performing operations on checked items──────────│",
550            "│☑ Test Song Test Artist                         │",
551            "│                                                │",
552            "└ ⏎ : Open | ←/↑/↓/→: Navigate | ␣ Check─────────┘",
553        ]);
554        assert_buffer_eq(&buffer, &expected);
555
556        // scroll up
557        view.handle_mouse_event(
558            MouseEvent {
559                kind: MouseEventKind::ScrollUp,
560                column: 2,
561                row: 3,
562                modifiers: KeyModifiers::empty(),
563            },
564            area,
565        );
566
567        // clicking on an empty area should clear the selection
568        let mouse = MouseEvent {
569            kind: MouseEventKind::Down(MouseButton::Left),
570            column: 2,
571            row: 4,
572            modifiers: KeyModifiers::empty(),
573        };
574        view.handle_mouse_event(mouse, area);
575        assert_eq!(view.tree_state.lock().unwrap().get_selected_thing(), None);
576        view.handle_mouse_event(mouse, area);
577        assert_eq!(
578            rx.try_recv(),
579            Err(tokio::sync::mpsc::error::TryRecvError::Empty)
580        );
581    }
582}