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_FOCUSED, BORDER_UNFOCUSED, 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                if let Some(selected) = self.random_type_list.selected() {
107                    let selected = if selected == 0 {
108                        RANDOM_TYPE_ITEMS.len() - 1
109                    } else {
110                        selected - 1
111                    };
112                    self.random_type_list.select(Some(selected));
113                } else {
114                    self.random_type_list
115                        .select(Some(RANDOM_TYPE_ITEMS.len() - 1));
116                }
117            }
118            // Move the selection down
119            KeyCode::Down => {
120                if let Some(selected) = self.random_type_list.selected() {
121                    let selected = if selected == RANDOM_TYPE_ITEMS.len() - 1 {
122                        0
123                    } else {
124                        selected + 1
125                    };
126                    self.random_type_list.select(Some(selected));
127                } else {
128                    self.random_type_list.select(Some(0));
129                }
130            }
131            // Select the current item
132            KeyCode::Enter => {
133                if let Some(selected) = self.random_type_list.selected() {
134                    if let Some(action) = RANDOM_TYPE_ITEMS.get(selected).and_then(|item| {
135                        self.props.as_ref().and_then(|props| item.to_action(props))
136                    }) {
137                        self.action_tx.send(action).unwrap();
138                    }
139                }
140            }
141            _ => {}
142        }
143    }
144
145    fn handle_mouse_event(&mut self, mouse: MouseEvent, area: Rect) {
146        let MouseEvent {
147            kind, column, row, ..
148        } = mouse;
149        let mouse_position = Position::new(column, row);
150
151        // adjust area to exclude the border
152        let area = area.inner(Margin::new(1, 1));
153
154        match kind {
155            MouseEventKind::Down(MouseButton::Left) if area.contains(mouse_position) => {
156                // adjust the mouse position so that it is relative to the area of the list
157                let adjusted_mouse_y = mouse_position.y - area.y;
158
159                // select the item at the mouse position
160                let selected = adjusted_mouse_y as usize;
161                if self.random_type_list.selected() == Some(selected) {
162                    self.handle_key_event(KeyEvent::from(KeyCode::Enter));
163                } else if selected < RANDOM_TYPE_ITEMS.len() {
164                    self.random_type_list.select(Some(selected));
165                } else {
166                    self.random_type_list.select(None);
167                }
168            }
169            MouseEventKind::ScrollDown => self.handle_key_event(KeyEvent::from(KeyCode::Down)),
170            MouseEventKind::ScrollUp => self.handle_key_event(KeyEvent::from(KeyCode::Up)),
171            _ => {}
172        }
173    }
174}
175
176impl ComponentRender<RenderProps> for RandomView {
177    fn render_border(&self, frame: &mut Frame, props: RenderProps) -> RenderProps {
178        let border_style = if props.is_focused {
179            Style::default().fg(BORDER_FOCUSED.into())
180        } else {
181            Style::default().fg(BORDER_UNFOCUSED.into())
182        };
183
184        let border = Block::bordered()
185            .title_top("Random")
186            .title_bottom(" \u{23CE} : select | ↑/↓: Move ")
187            .border_style(border_style);
188        frame.render_widget(&border, props.area);
189        let area = border.inner(props.area);
190
191        RenderProps { area, ..props }
192    }
193
194    fn render_content(&self, frame: &mut Frame, props: RenderProps) {
195        if self.props.is_some() {
196            let items = RANDOM_TYPE_ITEMS
197                .iter()
198                .map(|item| {
199                    ListItem::new(
200                        Span::styled(item.to_string(), Style::default().fg(TEXT_NORMAL.into()))
201                            .into_centered_line(),
202                    )
203                })
204                .collect::<Vec<_>>();
205
206            frame.render_stateful_widget(
207                List::new(items).highlight_style(Style::default().fg(TEXT_HIGHLIGHT.into()).bold()),
208                props.area,
209                &mut self.random_type_list.clone(),
210            );
211        } else {
212            frame.render_widget(
213                Line::from("Random items unavailable")
214                    .style(Style::default().fg(TEXT_NORMAL.into()))
215                    .alignment(Alignment::Center),
216                props.area,
217            );
218        }
219    }
220}
221
222#[cfg(test)]
223mod tests {
224    use super::*;
225    use crate::{
226        test_utils::{assert_buffer_eq, setup_test_terminal, state_with_everything},
227        ui::components::content_view::ActiveView,
228    };
229    use anyhow::Result;
230    use crossterm::event::{KeyModifiers, MouseButton, MouseEventKind};
231    use mecomp_storage::db::schemas::{album::Album, artist::Artist, song::Song};
232    use pretty_assertions::assert_eq;
233    use ratatui::buffer::Buffer;
234
235    #[test]
236    fn test_random_view_type_to_action() {
237        let props = RandomViewProps {
238            album: Album::generate_id().into(),
239            artist: Artist::generate_id().into(),
240            song: Song::generate_id().into(),
241        };
242
243        assert_eq!(
244            ItemType::Album.to_action(&props),
245            Some(Action::ActiveView(ViewAction::Set(ActiveView::Album(
246                props.album.id.clone()
247            ))))
248        );
249        assert_eq!(
250            ItemType::Artist.to_action(&props),
251            Some(Action::ActiveView(ViewAction::Set(ActiveView::Artist(
252                props.artist.id.clone()
253            ))))
254        );
255        assert_eq!(
256            ItemType::Song.to_action(&props),
257            Some(Action::ActiveView(ViewAction::Set(ActiveView::Song(
258                props.song.id.clone()
259            ))))
260        );
261    }
262
263    #[test]
264    fn test_random_view_type_display() {
265        assert_eq!(ItemType::Album.to_string(), "Random Album");
266        assert_eq!(ItemType::Artist.to_string(), "Random Artist");
267        assert_eq!(ItemType::Song.to_string(), "Random Song");
268    }
269
270    #[test]
271    fn test_new() {
272        let (tx, _) = tokio::sync::mpsc::unbounded_channel();
273        let state = state_with_everything();
274        let view = RandomView::new(&state, tx);
275
276        assert_eq!(view.name(), "Random");
277        assert!(view.props.is_some());
278        assert_eq!(view.props, state.additional_view_data.random);
279    }
280
281    #[test]
282    fn test_move_with_state() {
283        let (tx, _) = tokio::sync::mpsc::unbounded_channel();
284        let state = AppState::default();
285        let new_state = state_with_everything();
286        let view = RandomView::new(&state, tx).move_with_state(&new_state);
287
288        assert!(view.props.is_some());
289        assert_eq!(view.props, new_state.additional_view_data.random);
290    }
291
292    #[test]
293    /// Test rendering when there are no items available (e.g., empty library)
294    fn test_render_empty() -> Result<()> {
295        let (tx, _) = tokio::sync::mpsc::unbounded_channel();
296        let view = RandomView::new(&AppState::default(), tx);
297
298        let (mut terminal, area) = setup_test_terminal(29, 3);
299        let props = RenderProps {
300            area,
301            is_focused: true,
302        };
303        let buffer = terminal
304            .draw(|frame| view.render(frame, props))
305            .unwrap()
306            .buffer
307            .clone();
308        #[rustfmt::skip]
309        let expected = Buffer::with_lines([
310            "┌Random─────────────────────┐",
311            "│ Random items unavailable  │",
312            "└ ⏎ : select | ↑/↓: Move ───┘",
313        ]);
314
315        assert_buffer_eq(&buffer, &expected);
316
317        Ok(())
318    }
319
320    #[test]
321    fn test_render() -> Result<()> {
322        let (tx, _) = tokio::sync::mpsc::unbounded_channel();
323        let view = RandomView::new(&state_with_everything(), tx);
324
325        let (mut terminal, area) = setup_test_terminal(50, 5);
326        let props = RenderProps {
327            area,
328            is_focused: true,
329        };
330        let buffer = terminal
331            .draw(|frame| view.render(frame, props))
332            .unwrap()
333            .buffer
334            .clone();
335        let expected = Buffer::with_lines([
336            "┌Random──────────────────────────────────────────┐",
337            "│                  Random Album                  │",
338            "│                 Random Artist                  │",
339            "│                  Random Song                   │",
340            "└ ⏎ : select | ↑/↓: Move ────────────────────────┘",
341        ]);
342
343        assert_buffer_eq(&buffer, &expected);
344
345        Ok(())
346    }
347
348    #[test]
349    fn test_navigation_wraps() {
350        let (tx, _) = tokio::sync::mpsc::unbounded_channel();
351        let mut view = RandomView::new(&state_with_everything(), tx);
352
353        view.handle_key_event(KeyEvent::from(KeyCode::Up));
354        assert_eq!(
355            view.random_type_list.selected(),
356            Some(RANDOM_TYPE_ITEMS.len() - 1)
357        );
358
359        view.handle_key_event(KeyEvent::from(KeyCode::Down));
360        assert_eq!(view.random_type_list.selected(), Some(0));
361    }
362
363    #[test]
364    fn test_actions() {
365        let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
366        let state = state_with_everything();
367        let mut view = RandomView::new(&state, tx);
368        let random_view_props = state.additional_view_data.random.unwrap();
369
370        view.handle_key_event(KeyEvent::from(KeyCode::Down));
371        view.handle_key_event(KeyEvent::from(KeyCode::Enter));
372        assert_eq!(
373            rx.blocking_recv().unwrap(),
374            Action::ActiveView(ViewAction::Set(ActiveView::Album(
375                random_view_props.album.id,
376            )))
377        );
378
379        view.handle_key_event(KeyEvent::from(KeyCode::Down));
380        view.handle_key_event(KeyEvent::from(KeyCode::Enter));
381        assert_eq!(
382            rx.blocking_recv().unwrap(),
383            Action::ActiveView(ViewAction::Set(ActiveView::Artist(
384                random_view_props.artist.id,
385            )))
386        );
387
388        view.handle_key_event(KeyEvent::from(KeyCode::Down));
389        view.handle_key_event(KeyEvent::from(KeyCode::Enter));
390        assert_eq!(
391            rx.blocking_recv().unwrap(),
392            Action::ActiveView(ViewAction::Set(
393                ActiveView::Song(random_view_props.song.id,)
394            ))
395        );
396    }
397
398    #[test]
399    fn test_mouse() {
400        let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
401        let state = state_with_everything();
402        let mut view = RandomView::new(&state, tx);
403        let random_view_props = state.additional_view_data.random.unwrap();
404        let view_area = Rect::new(0, 0, 50, 6);
405
406        // select the first item by scrolling down
407        view.handle_mouse_event(
408            MouseEvent {
409                kind: MouseEventKind::ScrollDown,
410                column: 25,
411                row: 1,
412                modifiers: KeyModifiers::empty(),
413            },
414            view_area,
415        );
416        // click selected item
417        view.handle_mouse_event(
418            MouseEvent {
419                kind: MouseEventKind::Down(MouseButton::Left),
420                column: 25,
421                row: 1,
422                modifiers: KeyModifiers::empty(),
423            },
424            view_area,
425        );
426        assert_eq!(
427            rx.blocking_recv().unwrap(),
428            Action::ActiveView(ViewAction::Set(ActiveView::Album(
429                random_view_props.album.id.clone(),
430            )))
431        );
432
433        // select the second item by scrolling down
434        view.handle_mouse_event(
435            MouseEvent {
436                kind: MouseEventKind::ScrollDown,
437                column: 25,
438                row: 1,
439                modifiers: KeyModifiers::empty(),
440            },
441            view_area,
442        );
443        // click selected item
444        view.handle_mouse_event(
445            MouseEvent {
446                kind: MouseEventKind::Down(MouseButton::Left),
447                column: 25,
448                row: 2,
449                modifiers: KeyModifiers::empty(),
450            },
451            view_area,
452        );
453        assert_eq!(
454            rx.blocking_recv().unwrap(),
455            Action::ActiveView(ViewAction::Set(ActiveView::Artist(
456                random_view_props.artist.id,
457            )))
458        );
459
460        // select the first item by clicking on it
461        view.handle_mouse_event(
462            MouseEvent {
463                kind: MouseEventKind::Down(MouseButton::Left),
464                column: 25,
465                row: 1,
466                modifiers: KeyModifiers::empty(),
467            },
468            view_area,
469        );
470        // click selected item
471        view.handle_mouse_event(
472            MouseEvent {
473                kind: MouseEventKind::Down(MouseButton::Left),
474                column: 25,
475                row: 1,
476                modifiers: KeyModifiers::empty(),
477            },
478            view_area,
479        );
480        assert_eq!(
481            rx.blocking_recv().unwrap(),
482            Action::ActiveView(ViewAction::Set(ActiveView::Album(
483                random_view_props.album.id,
484            )))
485        );
486
487        // select the third item by clicking on it
488        view.handle_mouse_event(
489            MouseEvent {
490                kind: MouseEventKind::Down(MouseButton::Left),
491                column: 25,
492                row: 3,
493                modifiers: KeyModifiers::empty(),
494            },
495            view_area,
496        );
497        view.handle_mouse_event(
498            MouseEvent {
499                kind: MouseEventKind::Down(MouseButton::Left),
500                column: 25,
501                row: 3,
502                modifiers: KeyModifiers::empty(),
503            },
504            view_area,
505        );
506        assert_eq!(
507            rx.blocking_recv().unwrap(),
508            Action::ActiveView(ViewAction::Set(ActiveView::Song(random_view_props.song.id)))
509        );
510
511        // clicking on nothing should clear the selection
512        view.handle_mouse_event(
513            MouseEvent {
514                kind: MouseEventKind::Down(MouseButton::Left),
515                column: 25,
516                row: 4,
517                modifiers: KeyModifiers::empty(),
518            },
519            view_area,
520        );
521        assert_eq!(view.random_type_list.selected(), None);
522    }
523}