Skip to main content

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

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