1use std::sync::Mutex;
4
5use crossterm::event::{KeyCode, KeyEvent, MouseEvent};
6use ratatui::{
7 Frame,
8 layout::{Alignment, Margin, Rect},
9 style::{Style, Stylize},
10 text::{Line, Span},
11 widgets::{Block, Borders, Scrollbar, ScrollbarOrientation},
12};
13use tokio::sync::mpsc::UnboundedSender;
14
15use super::{RadioViewProps, checktree_utils::create_song_tree_leaf};
16use crate::{
17 state::action::{Action, AudioAction, PopupAction, QueueAction, ViewAction},
18 ui::{
19 AppState,
20 colors::{TEXT_HIGHLIGHT, TEXT_NORMAL, border_color},
21 components::{Component, ComponentRender, RenderProps},
22 widgets::{
23 popups::PopupType,
24 tree::{CheckTree, state::CheckTreeState},
25 },
26 },
27};
28
29#[allow(clippy::module_name_repetitions)]
30pub struct RadioView {
31 pub action_tx: UnboundedSender<Action>,
33 pub props: Option<RadioViewProps>,
35 tree_state: Mutex<CheckTreeState<String>>,
37}
38
39impl Component for RadioView {
40 fn new(state: &AppState, action_tx: UnboundedSender<Action>) -> Self
41 where
42 Self: Sized,
43 {
44 Self {
45 action_tx,
46 props: state.additional_view_data.radio.clone(),
47 tree_state: Mutex::new(CheckTreeState::default()),
48 }
49 }
50
51 fn move_with_state(self, state: &AppState) -> Self
52 where
53 Self: Sized,
54 {
55 if let Some(props) = &state.additional_view_data.radio {
56 Self {
57 props: Some(props.to_owned()),
58 tree_state: Mutex::new(CheckTreeState::default()),
59 ..self
60 }
61 } else {
62 self
63 }
64 }
65
66 fn name(&self) -> &'static str {
67 "Radio"
68 }
69
70 fn handle_key_event(&mut self, key: KeyEvent) {
71 match key.code {
72 KeyCode::PageUp => {
74 self.tree_state.lock().unwrap().select_relative(|current| {
75 let first = self
76 .props
77 .as_ref()
78 .map_or(0, |p| p.songs.len().saturating_sub(1));
79 current.map_or(first, |c| c.saturating_sub(10))
80 });
81 }
82 KeyCode::Up => {
83 self.tree_state.lock().unwrap().key_up();
84 }
85 KeyCode::PageDown => {
86 self.tree_state
87 .lock()
88 .unwrap()
89 .select_relative(|current| current.map_or(0, |c| c.saturating_add(10)));
90 }
91 KeyCode::Down => {
92 self.tree_state.lock().unwrap().key_down();
93 }
94 KeyCode::Left => {
95 self.tree_state.lock().unwrap().key_left();
96 }
97 KeyCode::Right => {
98 self.tree_state.lock().unwrap().key_right();
99 }
100 KeyCode::Char(' ') => {
101 self.tree_state.lock().unwrap().key_space();
102 }
103 KeyCode::Enter => {
105 if self.tree_state.lock().unwrap().toggle_selected() {
106 let things = self.tree_state.lock().unwrap().get_selected_thing();
107
108 if let Some(thing) = things {
109 self.action_tx
110 .send(Action::ActiveView(ViewAction::Set(thing.into())))
111 .unwrap();
112 }
113 }
114 }
115 KeyCode::Char('q') => {
117 let things = self.tree_state.lock().unwrap().get_checked_things();
118 if !things.is_empty() {
119 self.action_tx
120 .send(Action::Audio(AudioAction::Queue(QueueAction::Add(things))))
121 .unwrap();
122 } else if let Some(props) = &self.props {
123 self.action_tx
124 .send(Action::Audio(AudioAction::Queue(QueueAction::Add(
125 props.songs.iter().map(|s| s.id.clone()).collect(),
126 ))))
127 .expect("failed to send action");
128 }
129 }
130 KeyCode::Char('p') => {
132 let things = self.tree_state.lock().unwrap().get_checked_things();
133 if !things.is_empty() {
134 self.action_tx
135 .send(Action::Popup(PopupAction::Open(PopupType::Playlist(
136 things,
137 ))))
138 .unwrap();
139 } else if let Some(props) = &self.props {
140 self.action_tx
141 .send(Action::Popup(PopupAction::Open(PopupType::Playlist(
142 props.songs.iter().map(|s| s.id.clone()).collect(),
143 ))))
144 .expect("failed to send action");
145 }
146 }
147 _ => {}
148 }
149 }
150
151 fn handle_mouse_event(&mut self, mouse: MouseEvent, area: Rect) {
152 let area = area.inner(Margin::new(1, 1));
154 let area = Rect {
155 y: area.y + 2,
156 height: area.height - 2,
157 ..area
158 };
159
160 let result = self
161 .tree_state
162 .lock()
163 .unwrap()
164 .handle_mouse_event(mouse, area, false);
165 if let Some(action) = result {
166 self.action_tx.send(action).unwrap();
167 }
168 }
169}
170
171impl ComponentRender<RenderProps> for RadioView {
172 fn render_border(&self, frame: &mut Frame<'_>, props: RenderProps) -> RenderProps {
173 let border_style = Style::default().fg(border_color(props.is_focused).into());
174
175 let area = if let Some(state) = &self.props {
176 let border = Block::bordered()
177 .title_top(Line::from(vec![
178 Span::styled("Radio", Style::default().bold()),
179 Span::raw(" "),
180 Span::styled(format!("top {}", state.count), Style::default().italic()),
181 ]))
182 .title_bottom(" \u{23CE} : Open | ←/↑/↓/→: Navigate | \u{2423} Check")
183 .border_style(border_style);
184 frame.render_widget(&border, props.area);
185 let content_area = border.inner(props.area);
186
187 let border = Block::default()
189 .borders(Borders::TOP)
190 .title_top("q: add to queue | p: add to playlist")
191 .border_style(border_style);
192 frame.render_widget(&border, content_area);
193 let content_area = border.inner(content_area);
194
195 let border = Block::default()
197 .borders(Borders::TOP)
198 .title_top(Line::from(vec![
199 Span::raw("Performing operations on "),
200 Span::raw(
201 if self
202 .tree_state
203 .lock()
204 .unwrap()
205 .get_checked_things()
206 .is_empty()
207 {
208 "entire radio"
209 } else {
210 "checked items"
211 },
212 )
213 .fg(*TEXT_HIGHLIGHT),
214 ]))
215 .italic()
216 .border_style(border_style);
217 frame.render_widget(&border, content_area);
218 border.inner(content_area)
219 } else {
220 let border = Block::bordered()
221 .title_top("Radio")
222 .border_style(border_style);
223 frame.render_widget(&border, props.area);
224 border.inner(props.area)
225 };
226
227 RenderProps { area, ..props }
228 }
229
230 fn render_content(&self, frame: &mut Frame<'_>, props: RenderProps) {
231 if let Some(state) = &self.props {
232 let items = state
234 .songs
235 .iter()
236 .map(create_song_tree_leaf)
237 .collect::<Vec<_>>();
238
239 frame.render_stateful_widget(
241 CheckTree::new(&items)
242 .unwrap()
243 .highlight_style(Style::default().fg((*TEXT_HIGHLIGHT).into()).bold())
244 .experimental_scrollbar(Some(Scrollbar::new(
245 ScrollbarOrientation::VerticalRight,
246 ))),
247 props.area,
248 &mut self.tree_state.lock().unwrap(),
249 );
250 } else {
251 let text = "Empty Radio";
252
253 frame.render_widget(
254 Line::from(text)
255 .style(Style::default().fg((*TEXT_NORMAL).into()))
256 .alignment(Alignment::Center),
257 props.area,
258 );
259 }
260 }
261}
262
263#[cfg(test)]
264mod tests {
265 use super::*;
266 use crate::{
267 test_utils::{assert_buffer_eq, item_id, setup_test_terminal, state_with_everything},
268 ui::components::content_view::ActiveView,
269 };
270 use crossterm::event::{KeyModifiers, MouseButton, MouseEventKind};
271 use pretty_assertions::assert_eq;
272 use ratatui::buffer::Buffer;
273
274 #[test]
275 fn test_new() {
276 let (tx, _) = tokio::sync::mpsc::unbounded_channel();
277 let state = state_with_everything();
278 let view = RadioView::new(&state, tx);
279
280 assert_eq!(view.name(), "Radio");
281 assert_eq!(view.props, Some(state.additional_view_data.radio.unwrap()));
282 }
283
284 #[test]
285 fn test_move_with_state() {
286 let (tx, _) = tokio::sync::mpsc::unbounded_channel();
287 let state = AppState::default();
288 let new_state = state_with_everything();
289 let view = RadioView::new(&state, tx).move_with_state(&new_state);
290
291 assert_eq!(
292 view.props,
293 Some(new_state.additional_view_data.radio.unwrap())
294 );
295 }
296
297 #[test]
298 fn test_render_empty() {
299 let (tx, _) = tokio::sync::mpsc::unbounded_channel();
300 let view = RadioView::new(&AppState::default(), tx);
301
302 let (mut terminal, area) = setup_test_terminal(16, 3);
303 let props = RenderProps {
304 area,
305 is_focused: true,
306 };
307 let buffer = terminal
308 .draw(|frame| view.render(frame, props))
309 .unwrap()
310 .buffer
311 .clone();
312 #[rustfmt::skip]
313 let expected = Buffer::with_lines([
314 "┌Radio─────────┐",
315 "│ Empty Radio │",
316 "└──────────────┘",
317 ]);
318
319 assert_buffer_eq(&buffer, &expected);
320 }
321
322 #[test]
323 fn test_render() {
324 let (tx, _) = tokio::sync::mpsc::unbounded_channel();
325 let view = RadioView::new(&state_with_everything(), tx);
326
327 let (mut terminal, area) = setup_test_terminal(50, 6);
328 let props = RenderProps {
329 area,
330 is_focused: true,
331 };
332 let buffer = terminal
333 .draw(|frame| view.render(frame, props))
334 .unwrap()
335 .buffer
336 .clone();
337 let expected = Buffer::with_lines([
338 "┌Radio top 1─────────────────────────────────────┐",
339 "│q: add to queue | p: add to playlist────────────│",
340 "│Performing operations on entire radio───────────│",
341 "│☐ Test Song Test Artist │",
342 "│ │",
343 "└ ⏎ : Open | ←/↑/↓/→: Navigate | ␣ Check─────────┘",
344 ]);
345
346 assert_buffer_eq(&buffer, &expected);
347 }
348
349 #[test]
350 fn test_render_with_checked() {
351 let (tx, _) = tokio::sync::mpsc::unbounded_channel();
352 let mut view = RadioView::new(&state_with_everything(), tx);
353 let (mut terminal, area) = setup_test_terminal(50, 6);
354 let props = RenderProps {
355 area,
356 is_focused: true,
357 };
358 let buffer = terminal
359 .draw(|frame| view.render(frame, props))
360 .unwrap()
361 .buffer
362 .clone();
363 let expected = Buffer::with_lines([
364 "┌Radio top 1─────────────────────────────────────┐",
365 "│q: add to queue | p: add to playlist────────────│",
366 "│Performing operations on entire radio───────────│",
367 "│☐ Test Song Test Artist │",
368 "│ │",
369 "└ ⏎ : Open | ←/↑/↓/→: Navigate | ␣ Check─────────┘",
370 ]);
371 assert_buffer_eq(&buffer, &expected);
372
373 view.handle_key_event(KeyEvent::from(KeyCode::Down));
374 view.handle_key_event(KeyEvent::from(KeyCode::Char(' ')));
375
376 let buffer = terminal
377 .draw(|frame| view.render(frame, props))
378 .unwrap()
379 .buffer
380 .clone();
381 let expected = Buffer::with_lines([
382 "┌Radio top 1─────────────────────────────────────┐",
383 "│q: add to queue | p: add to playlist────────────│",
384 "│Performing operations on checked items──────────│",
385 "│☑ Test Song Test Artist │",
386 "│ │",
387 "└ ⏎ : Open | ←/↑/↓/→: Navigate | ␣ Check─────────┘",
388 ]);
389
390 assert_buffer_eq(&buffer, &expected);
391 }
392
393 #[test]
394 fn smoke_navigation() {
395 let (tx, _) = tokio::sync::mpsc::unbounded_channel();
396 let mut view = RadioView::new(&state_with_everything(), tx);
397
398 view.handle_key_event(KeyEvent::from(KeyCode::Up));
399 view.handle_key_event(KeyEvent::from(KeyCode::PageUp));
400 view.handle_key_event(KeyEvent::from(KeyCode::Down));
401 view.handle_key_event(KeyEvent::from(KeyCode::PageDown));
402 view.handle_key_event(KeyEvent::from(KeyCode::Left));
403 view.handle_key_event(KeyEvent::from(KeyCode::Right));
404 }
405
406 #[test]
407 fn test_actions() {
408 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
409 let mut view = RadioView::new(&state_with_everything(), tx);
410
411 let (mut terminal, area) = setup_test_terminal(50, 6);
413 let props = RenderProps {
414 area,
415 is_focused: true,
416 };
417 let _frame = terminal.draw(|frame| view.render(frame, props)).unwrap();
418
419 view.handle_key_event(KeyEvent::from(KeyCode::Char('q')));
422 assert_eq!(
423 rx.blocking_recv().unwrap(),
424 Action::Audio(AudioAction::Queue(QueueAction::Add(vec![
425 ("song", item_id()).into()
426 ])))
427 );
428 view.handle_key_event(KeyEvent::from(KeyCode::Char('p')));
429 assert_eq!(
430 rx.blocking_recv().unwrap(),
431 Action::Popup(PopupAction::Open(PopupType::Playlist(vec![
432 ("song", item_id()).into()
433 ])))
434 );
435
436 view.handle_key_event(KeyEvent::from(KeyCode::Down));
439 let _frame = terminal.draw(|frame| view.render(frame, props)).unwrap();
440
441 view.handle_key_event(KeyEvent::from(KeyCode::Enter));
443 assert_eq!(
444 rx.blocking_recv().unwrap(),
445 Action::ActiveView(ViewAction::Set(ActiveView::Song(item_id().into())))
446 );
447
448 view.handle_key_event(KeyEvent::from(KeyCode::Char(' ')));
450
451 view.handle_key_event(KeyEvent::from(KeyCode::Char('q')));
453 assert_eq!(
454 rx.blocking_recv().unwrap(),
455 Action::Audio(AudioAction::Queue(QueueAction::Add(vec![
456 ("song", item_id()).into()
457 ])))
458 );
459
460 view.handle_key_event(KeyEvent::from(KeyCode::Char('p')));
462 assert_eq!(
463 rx.blocking_recv().unwrap(),
464 Action::Popup(PopupAction::Open(PopupType::Playlist(vec![
465 ("song", item_id()).into()
466 ])))
467 );
468 }
469
470 #[test]
471 fn test_mouse() {
472 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
473 let mut view = RadioView::new(&state_with_everything(), tx);
474
475 let (mut terminal, area) = setup_test_terminal(50, 6);
477 let props = RenderProps {
478 area,
479 is_focused: true,
480 };
481 let buffer = terminal
482 .draw(|frame| view.render(frame, props))
483 .unwrap()
484 .buffer
485 .clone();
486 let expected = Buffer::with_lines([
487 "┌Radio top 1─────────────────────────────────────┐",
488 "│q: add to queue | p: add to playlist────────────│",
489 "│Performing operations on entire radio───────────│",
490 "│☐ Test Song Test Artist │",
491 "│ │",
492 "└ ⏎ : Open | ←/↑/↓/→: Navigate | ␣ Check─────────┘",
493 ]);
494 assert_buffer_eq(&buffer, &expected);
495
496 view.handle_mouse_event(
498 MouseEvent {
499 kind: MouseEventKind::ScrollDown,
500 column: 2,
501 row: 3,
502 modifiers: KeyModifiers::empty(),
503 },
504 area,
505 );
506
507 for _ in 0..2 {
509 view.handle_mouse_event(
510 MouseEvent {
511 kind: MouseEventKind::Down(MouseButton::Left),
512 column: 2,
513 row: 3,
514 modifiers: KeyModifiers::CONTROL,
515 },
516 area,
517 );
518 assert_eq!(
519 rx.blocking_recv().unwrap(),
520 Action::ActiveView(ViewAction::Set(ActiveView::Song(item_id().into())))
521 );
522 }
523
524 view.handle_mouse_event(
526 MouseEvent {
527 kind: MouseEventKind::Down(MouseButton::Left),
528 column: 2,
529 row: 3,
530 modifiers: KeyModifiers::empty(),
531 },
532 area,
533 );
534 let buffer = terminal
535 .draw(|frame| view.render(frame, props))
536 .unwrap()
537 .buffer
538 .clone();
539 let expected = Buffer::with_lines([
540 "┌Radio top 1─────────────────────────────────────┐",
541 "│q: add to queue | p: add to playlist────────────│",
542 "│Performing operations on checked items──────────│",
543 "│☑ Test Song Test Artist │",
544 "│ │",
545 "└ ⏎ : Open | ←/↑/↓/→: Navigate | ␣ Check─────────┘",
546 ]);
547 assert_buffer_eq(&buffer, &expected);
548 view.handle_mouse_event(
550 MouseEvent {
551 kind: MouseEventKind::Down(MouseButton::Left),
552 column: 2,
553 row: 3,
554 modifiers: KeyModifiers::CONTROL,
555 },
556 area,
557 );
558 assert_eq!(
559 rx.blocking_recv().unwrap(),
560 Action::ActiveView(ViewAction::Set(ActiveView::Song(item_id().into())))
561 );
562
563 view.handle_mouse_event(
565 MouseEvent {
566 kind: MouseEventKind::ScrollUp,
567 column: 2,
568 row: 3,
569 modifiers: KeyModifiers::empty(),
570 },
571 area,
572 );
573
574 let mouse = MouseEvent {
576 kind: MouseEventKind::Down(MouseButton::Left),
577 column: 2,
578 row: 4,
579 modifiers: KeyModifiers::empty(),
580 };
581 view.handle_mouse_event(mouse, area);
582 assert_eq!(view.tree_state.lock().unwrap().get_selected_thing(), None);
583 view.handle_mouse_event(mouse, area);
584 assert_eq!(
585 rx.try_recv(),
586 Err(tokio::sync::mpsc::error::TryRecvError::Empty)
587 );
588 }
589}