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