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

1//! implementation of the random view
2
3use std::fmt::Display;
4
5use crossterm::event::{KeyCode, KeyEvent, MouseButton, MouseEvent, MouseEventKind};
6use ratatui::{
7    layout::{Alignment, Margin, Position, Rect},
8    style::{Style, Stylize},
9    text::{Line, Span},
10    widgets::{Block, List, ListItem, ListState},
11    Frame,
12};
13use tokio::sync::mpsc::UnboundedSender;
14
15use super::RandomViewProps;
16use crate::{
17    state::action::{Action, ViewAction},
18    ui::{
19        colors::{border_color, TEXT_HIGHLIGHT, TEXT_NORMAL},
20        components::{content_view::ActiveView, Component, ComponentRender, RenderProps},
21        AppState,
22    },
23};
24
25#[derive(Debug, Clone, PartialEq, Eq)]
26/// The type of random item to get
27pub enum ItemType {
28    Album,
29    Artist,
30    Song,
31}
32
33impl ItemType {
34    #[must_use]
35    pub fn to_action(&self, props: &RandomViewProps) -> Option<Action> {
36        match self {
37            Self::Album => Some(Action::ActiveView(ViewAction::Set(ActiveView::Album(
38                props.album.id.clone(),
39            )))),
40            Self::Artist => Some(Action::ActiveView(ViewAction::Set(ActiveView::Artist(
41                props.artist.id.clone(),
42            )))),
43            Self::Song => Some(Action::ActiveView(ViewAction::Set(ActiveView::Song(
44                props.song.id.clone(),
45            )))),
46        }
47    }
48}
49
50impl Display for ItemType {
51    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
52        match self {
53            Self::Album => write!(f, "Random Album"),
54            Self::Artist => write!(f, "Random Artist"),
55            Self::Song => write!(f, "Random Song"),
56        }
57    }
58}
59
60const RANDOM_TYPE_ITEMS: [ItemType; 3] = [ItemType::Album, ItemType::Artist, ItemType::Song];
61
62#[allow(clippy::module_name_repetitions)]
63pub struct RandomView {
64    /// Action Sender
65    pub action_tx: UnboundedSender<Action>,
66    /// Props for the random view
67    pub props: Option<RandomViewProps>,
68    /// State of the list that users interact with to a random item of the selected type
69    random_type_list: ListState,
70}
71
72impl Component for RandomView {
73    fn new(state: &AppState, action_tx: UnboundedSender<Action>) -> Self
74    where
75        Self: Sized,
76    {
77        Self {
78            action_tx,
79            props: state.additional_view_data.random.clone(),
80            random_type_list: ListState::default(),
81        }
82    }
83
84    fn move_with_state(self, state: &AppState) -> Self
85    where
86        Self: Sized,
87    {
88        if let Some(props) = &state.additional_view_data.random {
89            Self {
90                props: Some(props.clone()),
91                ..self
92            }
93        } else {
94            self
95        }
96    }
97
98    fn name(&self) -> &'static str {
99        "Random"
100    }
101
102    fn handle_key_event(&mut self, key: KeyEvent) {
103        match key.code {
104            // Move the selection up
105            KeyCode::Up => {
106                let new_selection = self
107                    .random_type_list
108                    .selected()
109                    .filter(|selected| *selected > 0)
110                    .map_or_else(|| RANDOM_TYPE_ITEMS.len() - 1, |selected| selected - 1);
111
112                self.random_type_list.select(Some(new_selection));
113            }
114            // Move the selection down
115            KeyCode::Down => {
116                let new_selection = self
117                    .random_type_list
118                    .selected()
119                    .filter(|selected| *selected < RANDOM_TYPE_ITEMS.len() - 1)
120                    .map_or(0, |selected| selected + 1);
121
122                self.random_type_list.select(Some(new_selection));
123            }
124            // Select the current item
125            KeyCode::Enter => {
126                if let Some(selected) = self.random_type_list.selected() {
127                    if let Some(action) = RANDOM_TYPE_ITEMS.get(selected).and_then(|item| {
128                        self.props.as_ref().and_then(|props| item.to_action(props))
129                    }) {
130                        self.action_tx.send(action).unwrap();
131                    }
132                }
133            }
134            _ => {}
135        }
136    }
137
138    fn handle_mouse_event(&mut self, mouse: MouseEvent, area: Rect) {
139        let MouseEvent {
140            kind, column, row, ..
141        } = mouse;
142        let mouse_position = Position::new(column, row);
143
144        // adjust area to exclude the border
145        let area = area.inner(Margin::new(1, 1));
146
147        match kind {
148            MouseEventKind::Down(MouseButton::Left) if area.contains(mouse_position) => {
149                // adjust the mouse position so that it is relative to the area of the list
150                let adjusted_mouse_y = mouse_position.y - area.y;
151
152                // select the item at the mouse position
153                let selected = adjusted_mouse_y as usize;
154                if self.random_type_list.selected() == Some(selected) {
155                    self.handle_key_event(KeyEvent::from(KeyCode::Enter));
156                } else if selected < RANDOM_TYPE_ITEMS.len() {
157                    self.random_type_list.select(Some(selected));
158                } else {
159                    self.random_type_list.select(None);
160                }
161            }
162            MouseEventKind::ScrollDown => self.handle_key_event(KeyEvent::from(KeyCode::Down)),
163            MouseEventKind::ScrollUp => self.handle_key_event(KeyEvent::from(KeyCode::Up)),
164            _ => {}
165        }
166    }
167}
168
169impl ComponentRender<RenderProps> for RandomView {
170    fn render_border(&self, frame: &mut Frame, props: RenderProps) -> RenderProps {
171        let border_style = Style::default().fg(border_color(props.is_focused).into());
172
173        let border = Block::bordered()
174            .title_top("Random")
175            .title_bottom(" \u{23CE} : select | ↑/↓: Move ")
176            .border_style(border_style);
177        frame.render_widget(&border, props.area);
178        let area = border.inner(props.area);
179
180        RenderProps { area, ..props }
181    }
182
183    fn render_content(&self, frame: &mut Frame, props: RenderProps) {
184        if self.props.is_none() {
185            frame.render_widget(
186                Line::from("Random items unavailable")
187                    .style(Style::default().fg(TEXT_NORMAL.into()))
188                    .alignment(Alignment::Center),
189                props.area,
190            );
191            return;
192        }
193
194        let items = RANDOM_TYPE_ITEMS
195            .iter()
196            .map(|item| {
197                ListItem::new(
198                    Span::styled(item.to_string(), Style::default().fg(TEXT_NORMAL.into()))
199                        .into_centered_line(),
200                )
201            })
202            .collect::<Vec<_>>();
203
204        frame.render_stateful_widget(
205            List::new(items).highlight_style(Style::default().fg(TEXT_HIGHLIGHT.into()).bold()),
206            props.area,
207            &mut self.random_type_list.clone(),
208        );
209    }
210}
211
212#[cfg(test)]
213mod tests {
214    use super::*;
215    use crate::{
216        test_utils::{assert_buffer_eq, setup_test_terminal, state_with_everything},
217        ui::components::content_view::ActiveView,
218    };
219    use crossterm::event::{KeyModifiers, MouseButton, MouseEventKind};
220    use mecomp_storage::db::schemas::{album::Album, artist::Artist, song::Song};
221    use pretty_assertions::assert_eq;
222    use ratatui::buffer::Buffer;
223
224    #[test]
225    fn test_random_view_type_to_action() {
226        let props = RandomViewProps {
227            album: Album::generate_id().into(),
228            artist: Artist::generate_id().into(),
229            song: Song::generate_id().into(),
230        };
231
232        assert_eq!(
233            ItemType::Album.to_action(&props),
234            Some(Action::ActiveView(ViewAction::Set(ActiveView::Album(
235                props.album.id.clone()
236            ))))
237        );
238        assert_eq!(
239            ItemType::Artist.to_action(&props),
240            Some(Action::ActiveView(ViewAction::Set(ActiveView::Artist(
241                props.artist.id.clone()
242            ))))
243        );
244        assert_eq!(
245            ItemType::Song.to_action(&props),
246            Some(Action::ActiveView(ViewAction::Set(ActiveView::Song(
247                props.song.id.clone()
248            ))))
249        );
250    }
251
252    #[test]
253    fn test_random_view_type_display() {
254        assert_eq!(ItemType::Album.to_string(), "Random Album");
255        assert_eq!(ItemType::Artist.to_string(), "Random Artist");
256        assert_eq!(ItemType::Song.to_string(), "Random Song");
257    }
258
259    #[test]
260    fn test_new() {
261        let (tx, _) = tokio::sync::mpsc::unbounded_channel();
262        let state = state_with_everything();
263        let view = RandomView::new(&state, tx);
264
265        assert_eq!(view.name(), "Random");
266        assert!(view.props.is_some());
267        assert_eq!(view.props, state.additional_view_data.random);
268    }
269
270    #[test]
271    fn test_move_with_state() {
272        let (tx, _) = tokio::sync::mpsc::unbounded_channel();
273        let state = AppState::default();
274        let new_state = state_with_everything();
275        let view = RandomView::new(&state, tx).move_with_state(&new_state);
276
277        assert!(view.props.is_some());
278        assert_eq!(view.props, new_state.additional_view_data.random);
279    }
280
281    #[test]
282    /// Test rendering when there are no items available (e.g., empty library)
283    fn test_render_empty() {
284        let (tx, _) = tokio::sync::mpsc::unbounded_channel();
285        let view = RandomView::new(&AppState::default(), tx);
286
287        let (mut terminal, area) = setup_test_terminal(29, 3);
288        let props = RenderProps {
289            area,
290            is_focused: true,
291        };
292        let buffer = terminal
293            .draw(|frame| view.render(frame, props))
294            .unwrap()
295            .buffer
296            .clone();
297        #[rustfmt::skip]
298        let expected = Buffer::with_lines([
299            "┌Random─────────────────────┐",
300            "│ Random items unavailable  │",
301            "└ ⏎ : select | ↑/↓: Move ───┘",
302        ]);
303
304        assert_buffer_eq(&buffer, &expected);
305    }
306
307    #[test]
308    fn test_render() {
309        let (tx, _) = tokio::sync::mpsc::unbounded_channel();
310        let view = RandomView::new(&state_with_everything(), tx);
311
312        let (mut terminal, area) = setup_test_terminal(50, 5);
313        let props = RenderProps {
314            area,
315            is_focused: true,
316        };
317        let buffer = terminal
318            .draw(|frame| view.render(frame, props))
319            .unwrap()
320            .buffer
321            .clone();
322        let expected = Buffer::with_lines([
323            "┌Random──────────────────────────────────────────┐",
324            "│                  Random Album                  │",
325            "│                 Random Artist                  │",
326            "│                  Random Song                   │",
327            "└ ⏎ : select | ↑/↓: Move ────────────────────────┘",
328        ]);
329
330        assert_buffer_eq(&buffer, &expected);
331    }
332
333    #[test]
334    fn test_navigation_wraps() {
335        let (tx, _) = tokio::sync::mpsc::unbounded_channel();
336        let mut view = RandomView::new(&state_with_everything(), tx);
337
338        view.handle_key_event(KeyEvent::from(KeyCode::Up));
339        assert_eq!(
340            view.random_type_list.selected(),
341            Some(RANDOM_TYPE_ITEMS.len() - 1)
342        );
343
344        view.handle_key_event(KeyEvent::from(KeyCode::Down));
345        assert_eq!(view.random_type_list.selected(), Some(0));
346    }
347
348    #[test]
349    fn test_actions() {
350        let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
351        let state = state_with_everything();
352        let mut view = RandomView::new(&state, tx);
353        let random_view_props = state.additional_view_data.random.unwrap();
354
355        view.handle_key_event(KeyEvent::from(KeyCode::Down));
356        view.handle_key_event(KeyEvent::from(KeyCode::Enter));
357        assert_eq!(
358            rx.blocking_recv().unwrap(),
359            Action::ActiveView(ViewAction::Set(ActiveView::Album(
360                random_view_props.album.id,
361            )))
362        );
363
364        view.handle_key_event(KeyEvent::from(KeyCode::Down));
365        view.handle_key_event(KeyEvent::from(KeyCode::Enter));
366        assert_eq!(
367            rx.blocking_recv().unwrap(),
368            Action::ActiveView(ViewAction::Set(ActiveView::Artist(
369                random_view_props.artist.id,
370            )))
371        );
372
373        view.handle_key_event(KeyEvent::from(KeyCode::Down));
374        view.handle_key_event(KeyEvent::from(KeyCode::Enter));
375        assert_eq!(
376            rx.blocking_recv().unwrap(),
377            Action::ActiveView(ViewAction::Set(
378                ActiveView::Song(random_view_props.song.id,)
379            ))
380        );
381    }
382
383    #[test]
384    #[allow(clippy::too_many_lines)]
385    fn test_mouse() {
386        let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
387        let state = state_with_everything();
388        let mut view = RandomView::new(&state, tx);
389        let random_view_props = state.additional_view_data.random.unwrap();
390        let view_area = Rect::new(0, 0, 50, 6);
391
392        // select the first item by scrolling down
393        view.handle_mouse_event(
394            MouseEvent {
395                kind: MouseEventKind::ScrollDown,
396                column: 25,
397                row: 1,
398                modifiers: KeyModifiers::empty(),
399            },
400            view_area,
401        );
402        // click selected item
403        view.handle_mouse_event(
404            MouseEvent {
405                kind: MouseEventKind::Down(MouseButton::Left),
406                column: 25,
407                row: 1,
408                modifiers: KeyModifiers::empty(),
409            },
410            view_area,
411        );
412        assert_eq!(
413            rx.blocking_recv().unwrap(),
414            Action::ActiveView(ViewAction::Set(ActiveView::Album(
415                random_view_props.album.id.clone(),
416            )))
417        );
418
419        // select the second item by scrolling down
420        view.handle_mouse_event(
421            MouseEvent {
422                kind: MouseEventKind::ScrollDown,
423                column: 25,
424                row: 1,
425                modifiers: KeyModifiers::empty(),
426            },
427            view_area,
428        );
429        // click selected item
430        view.handle_mouse_event(
431            MouseEvent {
432                kind: MouseEventKind::Down(MouseButton::Left),
433                column: 25,
434                row: 2,
435                modifiers: KeyModifiers::empty(),
436            },
437            view_area,
438        );
439        assert_eq!(
440            rx.blocking_recv().unwrap(),
441            Action::ActiveView(ViewAction::Set(ActiveView::Artist(
442                random_view_props.artist.id,
443            )))
444        );
445
446        // select the first item by clicking on it
447        view.handle_mouse_event(
448            MouseEvent {
449                kind: MouseEventKind::Down(MouseButton::Left),
450                column: 25,
451                row: 1,
452                modifiers: KeyModifiers::empty(),
453            },
454            view_area,
455        );
456        // click selected item
457        view.handle_mouse_event(
458            MouseEvent {
459                kind: MouseEventKind::Down(MouseButton::Left),
460                column: 25,
461                row: 1,
462                modifiers: KeyModifiers::empty(),
463            },
464            view_area,
465        );
466        assert_eq!(
467            rx.blocking_recv().unwrap(),
468            Action::ActiveView(ViewAction::Set(ActiveView::Album(
469                random_view_props.album.id,
470            )))
471        );
472
473        // select the third item by clicking on it
474        view.handle_mouse_event(
475            MouseEvent {
476                kind: MouseEventKind::Down(MouseButton::Left),
477                column: 25,
478                row: 3,
479                modifiers: KeyModifiers::empty(),
480            },
481            view_area,
482        );
483        view.handle_mouse_event(
484            MouseEvent {
485                kind: MouseEventKind::Down(MouseButton::Left),
486                column: 25,
487                row: 3,
488                modifiers: KeyModifiers::empty(),
489            },
490            view_area,
491        );
492        assert_eq!(
493            rx.blocking_recv().unwrap(),
494            Action::ActiveView(ViewAction::Set(ActiveView::Song(random_view_props.song.id)))
495        );
496
497        // clicking on nothing should clear the selection
498        view.handle_mouse_event(
499            MouseEvent {
500                kind: MouseEventKind::Down(MouseButton::Left),
501                column: 25,
502                row: 4,
503                modifiers: KeyModifiers::empty(),
504            },
505            view_area,
506        );
507        assert_eq!(view.random_type_list.selected(), None);
508    }
509}