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 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 let area = area.inner(Margin::new(1, 1));
147
148 match kind {
149 MouseEventKind::Down(MouseButton::Left) if area.contains(mouse_position) => {
150 let adjusted_mouse_y = mouse_position.y - area.y;
152
153 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, setup_test_terminal, state_with_everything},
218 ui::components::content_view::ActiveView,
219 };
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 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.id,
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.id,
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(
379 ActiveView::Song(random_view_props.song.id,)
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 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 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.id.clone(),
417 )))
418 );
419
420 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 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.id,
444 )))
445 );
446
447 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 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.id,
471 )))
472 );
473
474 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(random_view_props.song.id)))
496 );
497
498 view.handle_mouse_event(
500 MouseEvent {
501 kind: MouseEventKind::Down(MouseButton::Left),
502 column: 25,
503 row: 4,
504 modifiers: KeyModifiers::empty(),
505 },
506 view_area,
507 );
508 assert_eq!(view.random_type_list.selected(), None);
509 }
510}