Skip to main content

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    Frame,
8    layout::{Alignment, Margin, Position, Rect},
9    style::Style,
10    text::{Line, Span},
11    widgets::{Block, List, ListItem, ListState},
12};
13use tokio::sync::mpsc::UnboundedSender;
14
15use super::RandomViewProps;
16use crate::{
17    state::action::{Action, ViewAction},
18    ui::{
19        AppState,
20        colors::{TEXT_HIGHLIGHT, TEXT_NORMAL, border_color},
21        components::{Component, ComponentRender, RenderProps, content_view::ActiveView},
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.ulid(),
39            )))),
40            Self::Artist => Some(Action::ActiveView(ViewAction::Set(ActiveView::Artist(
41                props.artist.ulid(),
42            )))),
43            Self::Song => Some(Action::ActiveView(ViewAction::Set(ActiveView::Song(
44                props.song.ulid(),
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                    let action = RANDOM_TYPE_ITEMS.get(selected).and_then(|item| {
128                        self.props.as_ref().and_then(|props| item.to_action(props))
129                    });
130                    if let Some(action) = action {
131                        self.action_tx.send(action).unwrap();
132                    }
133                }
134            }
135            _ => {}
136        }
137    }
138
139    fn handle_mouse_event(&mut self, mouse: MouseEvent, area: Rect) {
140        let MouseEvent {
141            kind, column, row, ..
142        } = mouse;
143        let mouse_position = Position::new(column, row);
144
145        // adjust area to exclude the border
146        let area = area.inner(Margin::new(1, 1));
147
148        match kind {
149            MouseEventKind::Down(MouseButton::Left) if area.contains(mouse_position) => {
150                // adjust the mouse position so that it is relative to the area of the list
151                let adjusted_mouse_y = mouse_position.y - area.y;
152
153                // select the item at the mouse position
154                let selected = adjusted_mouse_y as usize;
155                if self.random_type_list.selected() == Some(selected) {
156                    self.handle_key_event(KeyEvent::from(KeyCode::Enter));
157                } else if selected < RANDOM_TYPE_ITEMS.len() {
158                    self.random_type_list.select(Some(selected));
159                } else {
160                    self.random_type_list.select(None);
161                }
162            }
163            MouseEventKind::ScrollDown => self.handle_key_event(KeyEvent::from(KeyCode::Down)),
164            MouseEventKind::ScrollUp => self.handle_key_event(KeyEvent::from(KeyCode::Up)),
165            _ => {}
166        }
167    }
168}
169
170impl ComponentRender<RenderProps> for RandomView {
171    fn render_border(&self, frame: &mut Frame<'_>, props: RenderProps) -> RenderProps {
172        let border_style = Style::default().fg(border_color(props.is_focused).into());
173
174        let border = Block::bordered()
175            .title_top("Random")
176            .title_bottom(" \u{23CE} : select | ↑/↓: Move ")
177            .border_style(border_style);
178        frame.render_widget(&border, props.area);
179        let area = border.inner(props.area);
180
181        RenderProps { area, ..props }
182    }
183
184    fn render_content(&self, frame: &mut Frame<'_>, props: RenderProps) {
185        if self.props.is_none() {
186            frame.render_widget(
187                Line::from("Random items unavailable")
188                    .style(Style::default().fg((*TEXT_NORMAL).into()))
189                    .alignment(Alignment::Center),
190                props.area,
191            );
192            return;
193        }
194
195        let items = RANDOM_TYPE_ITEMS
196            .iter()
197            .map(|item| {
198                ListItem::new(
199                    Span::styled(item.to_string(), Style::default().fg((*TEXT_NORMAL).into()))
200                        .into_centered_line(),
201                )
202            })
203            .collect::<Vec<_>>();
204
205        frame.render_stateful_widget(
206            List::new(items).highlight_style(Style::default().fg((*TEXT_HIGHLIGHT).into()).bold()),
207            props.area,
208            &mut self.random_type_list.clone(),
209        );
210    }
211}
212
213#[cfg(test)]
214mod tests {
215    use super::*;
216    use crate::{
217        test_utils::{assert_buffer_eq, item_id, setup_test_terminal, state_with_everything},
218        ui::components::content_view::ActiveView,
219    };
220    use crossterm::event::{KeyModifiers, MouseButton, MouseEventKind};
221    use mecomp_prost::RecordId;
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: RecordId::new("album", item_id()),
229            artist: RecordId::new("artist", item_id()),
230            song: RecordId::new("song", item_id()),
231        };
232
233        assert_eq!(
234            ItemType::Album.to_action(&props),
235            Some(Action::ActiveView(ViewAction::Set(ActiveView::Album(
236                props.album.ulid()
237            ))))
238        );
239        assert_eq!(
240            ItemType::Artist.to_action(&props),
241            Some(Action::ActiveView(ViewAction::Set(ActiveView::Artist(
242                props.artist.ulid()
243            ))))
244        );
245        assert_eq!(
246            ItemType::Song.to_action(&props),
247            Some(Action::ActiveView(ViewAction::Set(ActiveView::Song(
248                props.song.ulid()
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() {
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
308    #[test]
309    fn test_render() {
310        let (tx, _) = tokio::sync::mpsc::unbounded_channel();
311        let view = RandomView::new(&state_with_everything(), tx);
312
313        let (mut terminal, area) = setup_test_terminal(50, 5);
314        let props = RenderProps {
315            area,
316            is_focused: true,
317        };
318        let buffer = terminal
319            .draw(|frame| view.render(frame, props))
320            .unwrap()
321            .buffer
322            .clone();
323        let expected = Buffer::with_lines([
324            "┌Random──────────────────────────────────────────┐",
325            "│                  Random Album                  │",
326            "│                 Random Artist                  │",
327            "│                  Random Song                   │",
328            "└ ⏎ : select | ↑/↓: Move ────────────────────────┘",
329        ]);
330
331        assert_buffer_eq(&buffer, &expected);
332    }
333
334    #[test]
335    fn test_navigation_wraps() {
336        let (tx, _) = tokio::sync::mpsc::unbounded_channel();
337        let mut view = RandomView::new(&state_with_everything(), tx);
338
339        view.handle_key_event(KeyEvent::from(KeyCode::Up));
340        assert_eq!(
341            view.random_type_list.selected(),
342            Some(RANDOM_TYPE_ITEMS.len() - 1)
343        );
344
345        view.handle_key_event(KeyEvent::from(KeyCode::Down));
346        assert_eq!(view.random_type_list.selected(), Some(0));
347    }
348
349    #[test]
350    fn test_actions() {
351        let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
352        let state = state_with_everything();
353        let mut view = RandomView::new(&state, tx);
354        let random_view_props = state.additional_view_data.random.unwrap();
355
356        view.handle_key_event(KeyEvent::from(KeyCode::Down));
357        view.handle_key_event(KeyEvent::from(KeyCode::Enter));
358        assert_eq!(
359            rx.blocking_recv().unwrap(),
360            Action::ActiveView(ViewAction::Set(ActiveView::Album(
361                random_view_props.album.ulid(),
362            )))
363        );
364
365        view.handle_key_event(KeyEvent::from(KeyCode::Down));
366        view.handle_key_event(KeyEvent::from(KeyCode::Enter));
367        assert_eq!(
368            rx.blocking_recv().unwrap(),
369            Action::ActiveView(ViewAction::Set(ActiveView::Artist(
370                random_view_props.artist.ulid(),
371            )))
372        );
373
374        view.handle_key_event(KeyEvent::from(KeyCode::Down));
375        view.handle_key_event(KeyEvent::from(KeyCode::Enter));
376        assert_eq!(
377            rx.blocking_recv().unwrap(),
378            Action::ActiveView(ViewAction::Set(ActiveView::Song(
379                random_view_props.song.ulid(),
380            )))
381        );
382    }
383
384    #[test]
385    #[allow(clippy::too_many_lines)]
386    fn test_mouse() {
387        let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
388        let state = state_with_everything();
389        let mut view = RandomView::new(&state, tx);
390        let random_view_props = state.additional_view_data.random.unwrap();
391        let view_area = Rect::new(0, 0, 50, 6);
392
393        // select the first item by scrolling down
394        view.handle_mouse_event(
395            MouseEvent {
396                kind: MouseEventKind::ScrollDown,
397                column: 25,
398                row: 1,
399                modifiers: KeyModifiers::empty(),
400            },
401            view_area,
402        );
403        // click selected item
404        view.handle_mouse_event(
405            MouseEvent {
406                kind: MouseEventKind::Down(MouseButton::Left),
407                column: 25,
408                row: 1,
409                modifiers: KeyModifiers::empty(),
410            },
411            view_area,
412        );
413        assert_eq!(
414            rx.blocking_recv().unwrap(),
415            Action::ActiveView(ViewAction::Set(ActiveView::Album(
416                random_view_props.album.ulid(),
417            )))
418        );
419
420        // select the second item by scrolling down
421        view.handle_mouse_event(
422            MouseEvent {
423                kind: MouseEventKind::ScrollDown,
424                column: 25,
425                row: 1,
426                modifiers: KeyModifiers::empty(),
427            },
428            view_area,
429        );
430        // click selected item
431        view.handle_mouse_event(
432            MouseEvent {
433                kind: MouseEventKind::Down(MouseButton::Left),
434                column: 25,
435                row: 2,
436                modifiers: KeyModifiers::empty(),
437            },
438            view_area,
439        );
440        assert_eq!(
441            rx.blocking_recv().unwrap(),
442            Action::ActiveView(ViewAction::Set(ActiveView::Artist(
443                random_view_props.artist.ulid(),
444            )))
445        );
446
447        // select the first item by clicking on it
448        view.handle_mouse_event(
449            MouseEvent {
450                kind: MouseEventKind::Down(MouseButton::Left),
451                column: 25,
452                row: 1,
453                modifiers: KeyModifiers::empty(),
454            },
455            view_area,
456        );
457        // click selected item
458        view.handle_mouse_event(
459            MouseEvent {
460                kind: MouseEventKind::Down(MouseButton::Left),
461                column: 25,
462                row: 1,
463                modifiers: KeyModifiers::empty(),
464            },
465            view_area,
466        );
467        assert_eq!(
468            rx.blocking_recv().unwrap(),
469            Action::ActiveView(ViewAction::Set(ActiveView::Album(
470                random_view_props.album.ulid(),
471            )))
472        );
473
474        // select the third item by clicking on it
475        view.handle_mouse_event(
476            MouseEvent {
477                kind: MouseEventKind::Down(MouseButton::Left),
478                column: 25,
479                row: 3,
480                modifiers: KeyModifiers::empty(),
481            },
482            view_area,
483        );
484        view.handle_mouse_event(
485            MouseEvent {
486                kind: MouseEventKind::Down(MouseButton::Left),
487                column: 25,
488                row: 3,
489                modifiers: KeyModifiers::empty(),
490            },
491            view_area,
492        );
493        assert_eq!(
494            rx.blocking_recv().unwrap(),
495            Action::ActiveView(ViewAction::Set(ActiveView::Song(
496                random_view_props.song.ulid()
497            )))
498        );
499
500        // clicking on nothing should clear the selection
501        view.handle_mouse_event(
502            MouseEvent {
503                kind: MouseEventKind::Down(MouseButton::Left),
504                column: 25,
505                row: 4,
506                modifiers: KeyModifiers::empty(),
507            },
508            view_area,
509        );
510        assert_eq!(view.random_type_list.selected(), None);
511    }
512}