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 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",
427                item_id()
428            )
429                .into()])))
430        );
431        view.handle_key_event(KeyEvent::from(KeyCode::Char('p')));
432        assert_eq!(
433            rx.blocking_recv().unwrap(),
434            Action::Popup(PopupAction::Open(PopupType::Playlist(vec![(
435                "song",
436                item_id()
437            )
438                .into()])))
439        );
440
441        // there are checked items
442        // first we need to select an item (the album)
443        view.handle_key_event(KeyEvent::from(KeyCode::Down));
444        let _frame = terminal.draw(|frame| view.render(frame, props)).unwrap();
445
446        // open the selected view
447        view.handle_key_event(KeyEvent::from(KeyCode::Enter));
448        assert_eq!(
449            rx.blocking_recv().unwrap(),
450            Action::ActiveView(ViewAction::Set(ActiveView::Song(item_id())))
451        );
452
453        // check the artist
454        view.handle_key_event(KeyEvent::from(KeyCode::Char(' ')));
455
456        // add to queue
457        view.handle_key_event(KeyEvent::from(KeyCode::Char('q')));
458        assert_eq!(
459            rx.blocking_recv().unwrap(),
460            Action::Audio(AudioAction::Queue(QueueAction::Add(vec![(
461                "song",
462                item_id()
463            )
464                .into()])))
465        );
466
467        // add to playlist
468        view.handle_key_event(KeyEvent::from(KeyCode::Char('p')));
469        assert_eq!(
470            rx.blocking_recv().unwrap(),
471            Action::Popup(PopupAction::Open(PopupType::Playlist(vec![(
472                "song",
473                item_id()
474            )
475                .into()])))
476        );
477    }
478
479    #[test]
480    fn test_mouse() {
481        let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
482        let mut view = RadioView::new(&state_with_everything(), tx);
483
484        // need to render the view at least once to load the tree state
485        let (mut terminal, area) = setup_test_terminal(50, 6);
486        let props = RenderProps {
487            area,
488            is_focused: true,
489        };
490        let buffer = terminal
491            .draw(|frame| view.render(frame, props))
492            .unwrap()
493            .buffer
494            .clone();
495        let expected = Buffer::with_lines([
496            "┌Radio top 1─────────────────────────────────────┐",
497            "│q: add to queue | p: add to playlist────────────│",
498            "│Performing operations on entire radio───────────│",
499            "│☐ Test Song Test Artist                         │",
500            "│                                                │",
501            "└ ⏎ : Open | ←/↑/↓/→: Navigate | ␣ Check─────────┘",
502        ]);
503        assert_buffer_eq(&buffer, &expected);
504
505        // scroll down
506        view.handle_mouse_event(
507            MouseEvent {
508                kind: MouseEventKind::ScrollDown,
509                column: 2,
510                row: 3,
511                modifiers: KeyModifiers::empty(),
512            },
513            area,
514        );
515
516        // click on the first item
517        view.handle_mouse_event(
518            MouseEvent {
519                kind: MouseEventKind::Down(MouseButton::Left),
520                column: 2,
521                row: 3,
522                modifiers: KeyModifiers::empty(),
523            },
524            area,
525        );
526        assert_eq!(
527            rx.blocking_recv().unwrap(),
528            Action::ActiveView(ViewAction::Set(ActiveView::Song(item_id())))
529        );
530        let buffer = terminal
531            .draw(|frame| view.render(frame, props))
532            .unwrap()
533            .buffer
534            .clone();
535        let expected = Buffer::with_lines([
536            "┌Radio top 1─────────────────────────────────────┐",
537            "│q: add to queue | p: add to playlist────────────│",
538            "│Performing operations on checked items──────────│",
539            "│☑ Test Song Test Artist                         │",
540            "│                                                │",
541            "└ ⏎ : Open | ←/↑/↓/→: Navigate | ␣ Check─────────┘",
542        ]);
543        assert_buffer_eq(&buffer, &expected);
544
545        // scroll up
546        view.handle_mouse_event(
547            MouseEvent {
548                kind: MouseEventKind::ScrollUp,
549                column: 2,
550                row: 3,
551                modifiers: KeyModifiers::empty(),
552            },
553            area,
554        );
555
556        // clicking on an empty area should clear the selection
557        let mouse = MouseEvent {
558            kind: MouseEventKind::Down(MouseButton::Left),
559            column: 2,
560            row: 4,
561            modifiers: KeyModifiers::empty(),
562        };
563        view.handle_mouse_event(mouse, area);
564        assert_eq!(view.tree_state.lock().unwrap().get_selected_thing(), None);
565        view.handle_mouse_event(mouse, area);
566        assert_eq!(
567            rx.try_recv(),
568            Err(tokio::sync::mpsc::error::TryRecvError::Empty)
569        );
570    }
571}