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_FOCUSED, BORDER_UNFOCUSED, 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 if let Some(selected) = self.random_type_list.selected() {
107 let selected = if selected == 0 {
108 RANDOM_TYPE_ITEMS.len() - 1
109 } else {
110 selected - 1
111 };
112 self.random_type_list.select(Some(selected));
113 } else {
114 self.random_type_list
115 .select(Some(RANDOM_TYPE_ITEMS.len() - 1));
116 }
117 }
118 KeyCode::Down => {
120 if let Some(selected) = self.random_type_list.selected() {
121 let selected = if selected == RANDOM_TYPE_ITEMS.len() - 1 {
122 0
123 } else {
124 selected + 1
125 };
126 self.random_type_list.select(Some(selected));
127 } else {
128 self.random_type_list.select(Some(0));
129 }
130 }
131 KeyCode::Enter => {
133 if let Some(selected) = self.random_type_list.selected() {
134 if let Some(action) = RANDOM_TYPE_ITEMS.get(selected).and_then(|item| {
135 self.props.as_ref().and_then(|props| item.to_action(props))
136 }) {
137 self.action_tx.send(action).unwrap();
138 }
139 }
140 }
141 _ => {}
142 }
143 }
144
145 fn handle_mouse_event(&mut self, mouse: MouseEvent, area: Rect) {
146 let MouseEvent {
147 kind, column, row, ..
148 } = mouse;
149 let mouse_position = Position::new(column, row);
150
151 let area = area.inner(Margin::new(1, 1));
153
154 match kind {
155 MouseEventKind::Down(MouseButton::Left) if area.contains(mouse_position) => {
156 let adjusted_mouse_y = mouse_position.y - area.y;
158
159 let selected = adjusted_mouse_y as usize;
161 if self.random_type_list.selected() == Some(selected) {
162 self.handle_key_event(KeyEvent::from(KeyCode::Enter));
163 } else if selected < RANDOM_TYPE_ITEMS.len() {
164 self.random_type_list.select(Some(selected));
165 } else {
166 self.random_type_list.select(None);
167 }
168 }
169 MouseEventKind::ScrollDown => self.handle_key_event(KeyEvent::from(KeyCode::Down)),
170 MouseEventKind::ScrollUp => self.handle_key_event(KeyEvent::from(KeyCode::Up)),
171 _ => {}
172 }
173 }
174}
175
176impl ComponentRender<RenderProps> for RandomView {
177 fn render_border(&self, frame: &mut Frame, props: RenderProps) -> RenderProps {
178 let border_style = if props.is_focused {
179 Style::default().fg(BORDER_FOCUSED.into())
180 } else {
181 Style::default().fg(BORDER_UNFOCUSED.into())
182 };
183
184 let border = Block::bordered()
185 .title_top("Random")
186 .title_bottom(" \u{23CE} : select | ↑/↓: Move ")
187 .border_style(border_style);
188 frame.render_widget(&border, props.area);
189 let area = border.inner(props.area);
190
191 RenderProps { area, ..props }
192 }
193
194 fn render_content(&self, frame: &mut Frame, props: RenderProps) {
195 if self.props.is_some() {
196 let items = RANDOM_TYPE_ITEMS
197 .iter()
198 .map(|item| {
199 ListItem::new(
200 Span::styled(item.to_string(), Style::default().fg(TEXT_NORMAL.into()))
201 .into_centered_line(),
202 )
203 })
204 .collect::<Vec<_>>();
205
206 frame.render_stateful_widget(
207 List::new(items).highlight_style(Style::default().fg(TEXT_HIGHLIGHT.into()).bold()),
208 props.area,
209 &mut self.random_type_list.clone(),
210 );
211 } else {
212 frame.render_widget(
213 Line::from("Random items unavailable")
214 .style(Style::default().fg(TEXT_NORMAL.into()))
215 .alignment(Alignment::Center),
216 props.area,
217 );
218 }
219 }
220}
221
222#[cfg(test)]
223mod tests {
224 use super::*;
225 use crate::{
226 test_utils::{assert_buffer_eq, setup_test_terminal, state_with_everything},
227 ui::components::content_view::ActiveView,
228 };
229 use anyhow::Result;
230 use crossterm::event::{KeyModifiers, MouseButton, MouseEventKind};
231 use mecomp_storage::db::schemas::{album::Album, artist::Artist, song::Song};
232 use pretty_assertions::assert_eq;
233 use ratatui::buffer::Buffer;
234
235 #[test]
236 fn test_random_view_type_to_action() {
237 let props = RandomViewProps {
238 album: Album::generate_id().into(),
239 artist: Artist::generate_id().into(),
240 song: Song::generate_id().into(),
241 };
242
243 assert_eq!(
244 ItemType::Album.to_action(&props),
245 Some(Action::ActiveView(ViewAction::Set(ActiveView::Album(
246 props.album.id.clone()
247 ))))
248 );
249 assert_eq!(
250 ItemType::Artist.to_action(&props),
251 Some(Action::ActiveView(ViewAction::Set(ActiveView::Artist(
252 props.artist.id.clone()
253 ))))
254 );
255 assert_eq!(
256 ItemType::Song.to_action(&props),
257 Some(Action::ActiveView(ViewAction::Set(ActiveView::Song(
258 props.song.id.clone()
259 ))))
260 );
261 }
262
263 #[test]
264 fn test_random_view_type_display() {
265 assert_eq!(ItemType::Album.to_string(), "Random Album");
266 assert_eq!(ItemType::Artist.to_string(), "Random Artist");
267 assert_eq!(ItemType::Song.to_string(), "Random Song");
268 }
269
270 #[test]
271 fn test_new() {
272 let (tx, _) = tokio::sync::mpsc::unbounded_channel();
273 let state = state_with_everything();
274 let view = RandomView::new(&state, tx);
275
276 assert_eq!(view.name(), "Random");
277 assert!(view.props.is_some());
278 assert_eq!(view.props, state.additional_view_data.random);
279 }
280
281 #[test]
282 fn test_move_with_state() {
283 let (tx, _) = tokio::sync::mpsc::unbounded_channel();
284 let state = AppState::default();
285 let new_state = state_with_everything();
286 let view = RandomView::new(&state, tx).move_with_state(&new_state);
287
288 assert!(view.props.is_some());
289 assert_eq!(view.props, new_state.additional_view_data.random);
290 }
291
292 #[test]
293 fn test_render_empty() -> Result<()> {
295 let (tx, _) = tokio::sync::mpsc::unbounded_channel();
296 let view = RandomView::new(&AppState::default(), tx);
297
298 let (mut terminal, area) = setup_test_terminal(29, 3);
299 let props = RenderProps {
300 area,
301 is_focused: true,
302 };
303 let buffer = terminal
304 .draw(|frame| view.render(frame, props))
305 .unwrap()
306 .buffer
307 .clone();
308 #[rustfmt::skip]
309 let expected = Buffer::with_lines([
310 "┌Random─────────────────────┐",
311 "│ Random items unavailable │",
312 "└ ⏎ : select | ↑/↓: Move ───┘",
313 ]);
314
315 assert_buffer_eq(&buffer, &expected);
316
317 Ok(())
318 }
319
320 #[test]
321 fn test_render() -> Result<()> {
322 let (tx, _) = tokio::sync::mpsc::unbounded_channel();
323 let view = RandomView::new(&state_with_everything(), tx);
324
325 let (mut terminal, area) = setup_test_terminal(50, 5);
326 let props = RenderProps {
327 area,
328 is_focused: true,
329 };
330 let buffer = terminal
331 .draw(|frame| view.render(frame, props))
332 .unwrap()
333 .buffer
334 .clone();
335 let expected = Buffer::with_lines([
336 "┌Random──────────────────────────────────────────┐",
337 "│ Random Album │",
338 "│ Random Artist │",
339 "│ Random Song │",
340 "└ ⏎ : select | ↑/↓: Move ────────────────────────┘",
341 ]);
342
343 assert_buffer_eq(&buffer, &expected);
344
345 Ok(())
346 }
347
348 #[test]
349 fn test_navigation_wraps() {
350 let (tx, _) = tokio::sync::mpsc::unbounded_channel();
351 let mut view = RandomView::new(&state_with_everything(), tx);
352
353 view.handle_key_event(KeyEvent::from(KeyCode::Up));
354 assert_eq!(
355 view.random_type_list.selected(),
356 Some(RANDOM_TYPE_ITEMS.len() - 1)
357 );
358
359 view.handle_key_event(KeyEvent::from(KeyCode::Down));
360 assert_eq!(view.random_type_list.selected(), Some(0));
361 }
362
363 #[test]
364 fn test_actions() {
365 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
366 let state = state_with_everything();
367 let mut view = RandomView::new(&state, tx);
368 let random_view_props = state.additional_view_data.random.unwrap();
369
370 view.handle_key_event(KeyEvent::from(KeyCode::Down));
371 view.handle_key_event(KeyEvent::from(KeyCode::Enter));
372 assert_eq!(
373 rx.blocking_recv().unwrap(),
374 Action::ActiveView(ViewAction::Set(ActiveView::Album(
375 random_view_props.album.id,
376 )))
377 );
378
379 view.handle_key_event(KeyEvent::from(KeyCode::Down));
380 view.handle_key_event(KeyEvent::from(KeyCode::Enter));
381 assert_eq!(
382 rx.blocking_recv().unwrap(),
383 Action::ActiveView(ViewAction::Set(ActiveView::Artist(
384 random_view_props.artist.id,
385 )))
386 );
387
388 view.handle_key_event(KeyEvent::from(KeyCode::Down));
389 view.handle_key_event(KeyEvent::from(KeyCode::Enter));
390 assert_eq!(
391 rx.blocking_recv().unwrap(),
392 Action::ActiveView(ViewAction::Set(
393 ActiveView::Song(random_view_props.song.id,)
394 ))
395 );
396 }
397
398 #[test]
399 fn test_mouse() {
400 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
401 let state = state_with_everything();
402 let mut view = RandomView::new(&state, tx);
403 let random_view_props = state.additional_view_data.random.unwrap();
404 let view_area = Rect::new(0, 0, 50, 6);
405
406 view.handle_mouse_event(
408 MouseEvent {
409 kind: MouseEventKind::ScrollDown,
410 column: 25,
411 row: 1,
412 modifiers: KeyModifiers::empty(),
413 },
414 view_area,
415 );
416 view.handle_mouse_event(
418 MouseEvent {
419 kind: MouseEventKind::Down(MouseButton::Left),
420 column: 25,
421 row: 1,
422 modifiers: KeyModifiers::empty(),
423 },
424 view_area,
425 );
426 assert_eq!(
427 rx.blocking_recv().unwrap(),
428 Action::ActiveView(ViewAction::Set(ActiveView::Album(
429 random_view_props.album.id.clone(),
430 )))
431 );
432
433 view.handle_mouse_event(
435 MouseEvent {
436 kind: MouseEventKind::ScrollDown,
437 column: 25,
438 row: 1,
439 modifiers: KeyModifiers::empty(),
440 },
441 view_area,
442 );
443 view.handle_mouse_event(
445 MouseEvent {
446 kind: MouseEventKind::Down(MouseButton::Left),
447 column: 25,
448 row: 2,
449 modifiers: KeyModifiers::empty(),
450 },
451 view_area,
452 );
453 assert_eq!(
454 rx.blocking_recv().unwrap(),
455 Action::ActiveView(ViewAction::Set(ActiveView::Artist(
456 random_view_props.artist.id,
457 )))
458 );
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 view.handle_mouse_event(
472 MouseEvent {
473 kind: MouseEventKind::Down(MouseButton::Left),
474 column: 25,
475 row: 1,
476 modifiers: KeyModifiers::empty(),
477 },
478 view_area,
479 );
480 assert_eq!(
481 rx.blocking_recv().unwrap(),
482 Action::ActiveView(ViewAction::Set(ActiveView::Album(
483 random_view_props.album.id,
484 )))
485 );
486
487 view.handle_mouse_event(
489 MouseEvent {
490 kind: MouseEventKind::Down(MouseButton::Left),
491 column: 25,
492 row: 3,
493 modifiers: KeyModifiers::empty(),
494 },
495 view_area,
496 );
497 view.handle_mouse_event(
498 MouseEvent {
499 kind: MouseEventKind::Down(MouseButton::Left),
500 column: 25,
501 row: 3,
502 modifiers: KeyModifiers::empty(),
503 },
504 view_area,
505 );
506 assert_eq!(
507 rx.blocking_recv().unwrap(),
508 Action::ActiveView(ViewAction::Set(ActiveView::Song(random_view_props.song.id)))
509 );
510
511 view.handle_mouse_event(
513 MouseEvent {
514 kind: MouseEventKind::Down(MouseButton::Left),
515 column: 25,
516 row: 4,
517 modifiers: KeyModifiers::empty(),
518 },
519 view_area,
520 );
521 assert_eq!(view.random_type_list.selected(), None);
522 }
523}