Skip to main content

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