1use 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)]
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 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 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 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 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 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 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 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 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 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 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}