1use 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, Stylize},
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)]
26pub 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 pub action_tx: UnboundedSender<Action>,
66 pub props: Option<RandomViewProps>,
68 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 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 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 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 let area = area.inner(Margin::new(1, 1));
146
147 match kind {
148 MouseEventKind::Down(MouseButton::Left) if area.contains(mouse_position) => {
149 let adjusted_mouse_y = mouse_position.y - area.y;
151
152 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 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 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 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 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 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 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 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 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 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}