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 anyhow::Result;
220    use crossterm::event::{KeyModifiers, MouseButton, MouseEventKind};
221    use mecomp_storage::db::schemas::{album::Album, artist::Artist, song::Song};
222    use pretty_assertions::assert_eq;
223    use ratatui::buffer::Buffer;
224
225    #[test]
226    fn test_random_view_type_to_action() {
227        let props = RandomViewProps {
228            album: Album::generate_id().into(),
229            artist: Artist::generate_id().into(),
230            song: Song::generate_id().into(),
231        };
232
233        assert_eq!(
234            ItemType::Album.to_action(&props),
235            Some(Action::ActiveView(ViewAction::Set(ActiveView::Album(
236                props.album.id.clone()
237            ))))
238        );
239        assert_eq!(
240            ItemType::Artist.to_action(&props),
241            Some(Action::ActiveView(ViewAction::Set(ActiveView::Artist(
242                props.artist.id.clone()
243            ))))
244        );
245        assert_eq!(
246            ItemType::Song.to_action(&props),
247            Some(Action::ActiveView(ViewAction::Set(ActiveView::Song(
248                props.song.id.clone()
249            ))))
250        );
251    }
252
253    #[test]
254    fn test_random_view_type_display() {
255        assert_eq!(ItemType::Album.to_string(), "Random Album");
256        assert_eq!(ItemType::Artist.to_string(), "Random Artist");
257        assert_eq!(ItemType::Song.to_string(), "Random Song");
258    }
259
260    #[test]
261    fn test_new() {
262        let (tx, _) = tokio::sync::mpsc::unbounded_channel();
263        let state = state_with_everything();
264        let view = RandomView::new(&state, tx);
265
266        assert_eq!(view.name(), "Random");
267        assert!(view.props.is_some());
268        assert_eq!(view.props, state.additional_view_data.random);
269    }
270
271    #[test]
272    fn test_move_with_state() {
273        let (tx, _) = tokio::sync::mpsc::unbounded_channel();
274        let state = AppState::default();
275        let new_state = state_with_everything();
276        let view = RandomView::new(&state, tx).move_with_state(&new_state);
277
278        assert!(view.props.is_some());
279        assert_eq!(view.props, new_state.additional_view_data.random);
280    }
281
282    #[test]
283    /// Test rendering when there are no items available (e.g., empty library)
284    fn test_render_empty() -> Result<()> {
285        let (tx, _) = tokio::sync::mpsc::unbounded_channel();
286        let view = RandomView::new(&AppState::default(), tx);
287
288        let (mut terminal, area) = setup_test_terminal(29, 3);
289        let props = RenderProps {
290            area,
291            is_focused: true,
292        };
293        let buffer = terminal
294            .draw(|frame| view.render(frame, props))
295            .unwrap()
296            .buffer
297            .clone();
298        #[rustfmt::skip]
299        let expected = Buffer::with_lines([
300            "┌Random─────────────────────┐",
301            "│ Random items unavailable  │",
302            "└ ⏎ : select | ↑/↓: Move ───┘",
303        ]);
304
305        assert_buffer_eq(&buffer, &expected);
306
307        Ok(())
308    }
309
310    #[test]
311    fn test_render() -> Result<()> {
312        let (tx, _) = tokio::sync::mpsc::unbounded_channel();
313        let view = RandomView::new(&state_with_everything(), tx);
314
315        let (mut terminal, area) = setup_test_terminal(50, 5);
316        let props = RenderProps {
317            area,
318            is_focused: true,
319        };
320        let buffer = terminal
321            .draw(|frame| view.render(frame, props))
322            .unwrap()
323            .buffer
324            .clone();
325        let expected = Buffer::with_lines([
326            "┌Random──────────────────────────────────────────┐",
327            "│                  Random Album                  │",
328            "│                 Random Artist                  │",
329            "│                  Random Song                   │",
330            "└ ⏎ : select | ↑/↓: Move ────────────────────────┘",
331        ]);
332
333        assert_buffer_eq(&buffer, &expected);
334
335        Ok(())
336    }
337
338    #[test]
339    fn test_navigation_wraps() {
340        let (tx, _) = tokio::sync::mpsc::unbounded_channel();
341        let mut view = RandomView::new(&state_with_everything(), tx);
342
343        view.handle_key_event(KeyEvent::from(KeyCode::Up));
344        assert_eq!(
345            view.random_type_list.selected(),
346            Some(RANDOM_TYPE_ITEMS.len() - 1)
347        );
348
349        view.handle_key_event(KeyEvent::from(KeyCode::Down));
350        assert_eq!(view.random_type_list.selected(), Some(0));
351    }
352
353    #[test]
354    fn test_actions() {
355        let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
356        let state = state_with_everything();
357        let mut view = RandomView::new(&state, tx);
358        let random_view_props = state.additional_view_data.random.unwrap();
359
360        view.handle_key_event(KeyEvent::from(KeyCode::Down));
361        view.handle_key_event(KeyEvent::from(KeyCode::Enter));
362        assert_eq!(
363            rx.blocking_recv().unwrap(),
364            Action::ActiveView(ViewAction::Set(ActiveView::Album(
365                random_view_props.album.id,
366            )))
367        );
368
369        view.handle_key_event(KeyEvent::from(KeyCode::Down));
370        view.handle_key_event(KeyEvent::from(KeyCode::Enter));
371        assert_eq!(
372            rx.blocking_recv().unwrap(),
373            Action::ActiveView(ViewAction::Set(ActiveView::Artist(
374                random_view_props.artist.id,
375            )))
376        );
377
378        view.handle_key_event(KeyEvent::from(KeyCode::Down));
379        view.handle_key_event(KeyEvent::from(KeyCode::Enter));
380        assert_eq!(
381            rx.blocking_recv().unwrap(),
382            Action::ActiveView(ViewAction::Set(
383                ActiveView::Song(random_view_props.song.id,)
384            ))
385        );
386    }
387
388    #[test]
389    fn test_mouse() {
390        let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
391        let state = state_with_everything();
392        let mut view = RandomView::new(&state, tx);
393        let random_view_props = state.additional_view_data.random.unwrap();
394        let view_area = Rect::new(0, 0, 50, 6);
395
396        // select the first item by scrolling down
397        view.handle_mouse_event(
398            MouseEvent {
399                kind: MouseEventKind::ScrollDown,
400                column: 25,
401                row: 1,
402                modifiers: KeyModifiers::empty(),
403            },
404            view_area,
405        );
406        // click selected item
407        view.handle_mouse_event(
408            MouseEvent {
409                kind: MouseEventKind::Down(MouseButton::Left),
410                column: 25,
411                row: 1,
412                modifiers: KeyModifiers::empty(),
413            },
414            view_area,
415        );
416        assert_eq!(
417            rx.blocking_recv().unwrap(),
418            Action::ActiveView(ViewAction::Set(ActiveView::Album(
419                random_view_props.album.id.clone(),
420            )))
421        );
422
423        // select the second item by scrolling down
424        view.handle_mouse_event(
425            MouseEvent {
426                kind: MouseEventKind::ScrollDown,
427                column: 25,
428                row: 1,
429                modifiers: KeyModifiers::empty(),
430            },
431            view_area,
432        );
433        // click selected item
434        view.handle_mouse_event(
435            MouseEvent {
436                kind: MouseEventKind::Down(MouseButton::Left),
437                column: 25,
438                row: 2,
439                modifiers: KeyModifiers::empty(),
440            },
441            view_area,
442        );
443        assert_eq!(
444            rx.blocking_recv().unwrap(),
445            Action::ActiveView(ViewAction::Set(ActiveView::Artist(
446                random_view_props.artist.id,
447            )))
448        );
449
450        // select the first item by clicking on it
451        view.handle_mouse_event(
452            MouseEvent {
453                kind: MouseEventKind::Down(MouseButton::Left),
454                column: 25,
455                row: 1,
456                modifiers: KeyModifiers::empty(),
457            },
458            view_area,
459        );
460        // click selected item
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        assert_eq!(
471            rx.blocking_recv().unwrap(),
472            Action::ActiveView(ViewAction::Set(ActiveView::Album(
473                random_view_props.album.id,
474            )))
475        );
476
477        // select the third item by clicking on it
478        view.handle_mouse_event(
479            MouseEvent {
480                kind: MouseEventKind::Down(MouseButton::Left),
481                column: 25,
482                row: 3,
483                modifiers: KeyModifiers::empty(),
484            },
485            view_area,
486        );
487        view.handle_mouse_event(
488            MouseEvent {
489                kind: MouseEventKind::Down(MouseButton::Left),
490                column: 25,
491                row: 3,
492                modifiers: KeyModifiers::empty(),
493            },
494            view_area,
495        );
496        assert_eq!(
497            rx.blocking_recv().unwrap(),
498            Action::ActiveView(ViewAction::Set(ActiveView::Song(random_view_props.song.id)))
499        );
500
501        // clicking on nothing should clear the selection
502        view.handle_mouse_event(
503            MouseEvent {
504                kind: MouseEventKind::Down(MouseButton::Left),
505                column: 25,
506                row: 4,
507                modifiers: KeyModifiers::empty(),
508            },
509            view_area,
510        );
511        assert_eq!(view.random_type_list.selected(), None);
512    }
513}